Merge branch 'next-release' into ai/paper-dynasty-database#102
This commit is contained in:
commit
3d7ff5a86c
@ -55,8 +55,6 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=registry,ref=manticorum67/paper-dynasty-database:buildcache
|
||||
cache-to: type=registry,ref=manticorum67/paper-dynasty-database:buildcache,mode=max
|
||||
|
||||
- name: Tag release
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@ -1,41 +1,12 @@
|
||||
FROM tiangolo/uvicorn-gunicorn-fastapi:latest
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Chrome dependency Instalation
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# fonts-liberation \
|
||||
# libasound2 \
|
||||
# libatk-bridge2.0-0 \
|
||||
# libatk1.0-0 \
|
||||
# libatspi2.0-0 \
|
||||
# libcups2 \
|
||||
# libdbus-1-3 \
|
||||
# libdrm2 \
|
||||
# libgbm1 \
|
||||
# libgtk-3-0 \
|
||||
# # libgtk-4-1 \
|
||||
# libnspr4 \
|
||||
# libnss3 \
|
||||
# libwayland-client0 \
|
||||
# libxcomposite1 \
|
||||
# libxdamage1 \
|
||||
# libxfixes3 \
|
||||
# libxkbcommon0 \
|
||||
# libxrandr2 \
|
||||
# xdg-utils \
|
||||
# libu2f-udev \
|
||||
# libvulkan1
|
||||
# # Chrome instalation
|
||||
# RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
# RUN apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||
# RUN rm google-chrome-stable_current_amd64.deb
|
||||
# # Check chrome version
|
||||
# RUN echo "Chrome: " && google-chrome --version
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium
|
||||
RUN playwright install-deps chromium
|
||||
|
||||
COPY ./app /app/app
|
||||
COPY ./app /usr/src/app/app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
|
||||
203
app/db_engine.py
203
app/db_engine.py
@ -1050,8 +1050,129 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique=
|
||||
Decision.add_index(decision_index)
|
||||
|
||||
|
||||
class BattingSeasonStats(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
season = IntegerField()
|
||||
games = IntegerField(default=0)
|
||||
pa = IntegerField(default=0)
|
||||
ab = IntegerField(default=0)
|
||||
hits = IntegerField(default=0)
|
||||
doubles = IntegerField(default=0)
|
||||
triples = IntegerField(default=0)
|
||||
hr = IntegerField(default=0)
|
||||
rbi = IntegerField(default=0)
|
||||
runs = IntegerField(default=0)
|
||||
bb = IntegerField(default=0)
|
||||
strikeouts = IntegerField(default=0)
|
||||
hbp = IntegerField(default=0)
|
||||
sac = IntegerField(default=0)
|
||||
ibb = IntegerField(default=0)
|
||||
gidp = IntegerField(default=0)
|
||||
sb = IntegerField(default=0)
|
||||
cs = IntegerField(default=0)
|
||||
last_game = ForeignKeyField(StratGame, null=True)
|
||||
last_updated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "batting_season_stats"
|
||||
|
||||
|
||||
bss_unique_index = ModelIndex(
|
||||
BattingSeasonStats,
|
||||
(BattingSeasonStats.player, BattingSeasonStats.team, BattingSeasonStats.season),
|
||||
unique=True,
|
||||
)
|
||||
BattingSeasonStats.add_index(bss_unique_index)
|
||||
|
||||
bss_team_season_index = ModelIndex(
|
||||
BattingSeasonStats,
|
||||
(BattingSeasonStats.team, BattingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
BattingSeasonStats.add_index(bss_team_season_index)
|
||||
|
||||
bss_player_season_index = ModelIndex(
|
||||
BattingSeasonStats,
|
||||
(BattingSeasonStats.player, BattingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
BattingSeasonStats.add_index(bss_player_season_index)
|
||||
|
||||
|
||||
class PitchingSeasonStats(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
season = IntegerField()
|
||||
games = IntegerField(default=0)
|
||||
games_started = IntegerField(default=0)
|
||||
outs = IntegerField(default=0)
|
||||
strikeouts = IntegerField(default=0)
|
||||
bb = IntegerField(default=0)
|
||||
hits_allowed = IntegerField(default=0)
|
||||
runs_allowed = IntegerField(default=0)
|
||||
earned_runs = IntegerField(default=0)
|
||||
hr_allowed = IntegerField(default=0)
|
||||
hbp = IntegerField(default=0)
|
||||
wild_pitches = IntegerField(default=0)
|
||||
balks = IntegerField(default=0)
|
||||
wins = IntegerField(default=0)
|
||||
losses = IntegerField(default=0)
|
||||
holds = IntegerField(default=0)
|
||||
saves = IntegerField(default=0)
|
||||
blown_saves = IntegerField(default=0)
|
||||
last_game = ForeignKeyField(StratGame, null=True)
|
||||
last_updated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "pitching_season_stats"
|
||||
|
||||
|
||||
pitss_unique_index = ModelIndex(
|
||||
PitchingSeasonStats,
|
||||
(PitchingSeasonStats.player, PitchingSeasonStats.team, PitchingSeasonStats.season),
|
||||
unique=True,
|
||||
)
|
||||
PitchingSeasonStats.add_index(pitss_unique_index)
|
||||
|
||||
pitss_team_season_index = ModelIndex(
|
||||
PitchingSeasonStats,
|
||||
(PitchingSeasonStats.team, PitchingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PitchingSeasonStats.add_index(pitss_team_season_index)
|
||||
|
||||
pitss_player_season_index = ModelIndex(
|
||||
PitchingSeasonStats,
|
||||
(PitchingSeasonStats.player, PitchingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PitchingSeasonStats.add_index(pitss_player_season_index)
|
||||
|
||||
|
||||
class ProcessedGame(BaseModel):
|
||||
game = ForeignKeyField(StratGame, primary_key=True)
|
||||
processed_at = DateTimeField(default=datetime.now)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "processed_game"
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
db.create_tables([StratGame, StratPlay, Decision], safe=True)
|
||||
db.create_tables(
|
||||
[
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
],
|
||||
safe=True,
|
||||
)
|
||||
|
||||
|
||||
class ScoutOpportunity(BaseModel):
|
||||
@ -1089,6 +1210,86 @@ if not SKIP_TABLE_CREATION:
|
||||
db.create_tables([ScoutOpportunity, ScoutClaim], safe=True)
|
||||
|
||||
|
||||
class EvolutionTrack(BaseModel):
|
||||
name = CharField(unique=True)
|
||||
card_type = CharField() # 'batter', 'sp', 'rp'
|
||||
formula = CharField() # e.g. "pa + tb * 2"
|
||||
t1_threshold = IntegerField()
|
||||
t2_threshold = IntegerField()
|
||||
t3_threshold = IntegerField()
|
||||
t4_threshold = IntegerField()
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_track"
|
||||
|
||||
|
||||
class EvolutionCardState(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
track = ForeignKeyField(EvolutionTrack)
|
||||
current_tier = IntegerField(default=0) # 0-4
|
||||
current_value = FloatField(default=0.0)
|
||||
fully_evolved = BooleanField(default=False)
|
||||
last_evaluated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_card_state"
|
||||
|
||||
|
||||
evolution_card_state_index = ModelIndex(
|
||||
EvolutionCardState,
|
||||
(EvolutionCardState.player, EvolutionCardState.team),
|
||||
unique=True,
|
||||
)
|
||||
EvolutionCardState.add_index(evolution_card_state_index)
|
||||
|
||||
|
||||
class EvolutionTierBoost(BaseModel):
|
||||
track = ForeignKeyField(EvolutionTrack)
|
||||
tier = IntegerField() # 1-4
|
||||
boost_type = CharField() # e.g. 'rating', 'stat'
|
||||
boost_target = CharField() # e.g. 'contact_vl', 'power_vr'
|
||||
boost_value = FloatField(default=0.0)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_tier_boost"
|
||||
|
||||
|
||||
evolution_tier_boost_index = ModelIndex(
|
||||
EvolutionTierBoost,
|
||||
(
|
||||
EvolutionTierBoost.track,
|
||||
EvolutionTierBoost.tier,
|
||||
EvolutionTierBoost.boost_type,
|
||||
EvolutionTierBoost.boost_target,
|
||||
),
|
||||
unique=True,
|
||||
)
|
||||
EvolutionTierBoost.add_index(evolution_tier_boost_index)
|
||||
|
||||
|
||||
class EvolutionCosmetic(BaseModel):
|
||||
name = CharField(unique=True)
|
||||
tier_required = IntegerField(default=0)
|
||||
cosmetic_type = CharField() # 'frame', 'badge', 'theme'
|
||||
css_class = CharField(null=True)
|
||||
asset_url = CharField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_cosmetic"
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
db.create_tables(
|
||||
[EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic],
|
||||
safe=True,
|
||||
)
|
||||
|
||||
|
||||
db.close()
|
||||
|
||||
# scout_db = SqliteDatabase(
|
||||
|
||||
10
app/main.py
10
app/main.py
@ -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,
|
||||
@ -51,6 +51,8 @@ from .routers_v2 import (
|
||||
stratplays,
|
||||
scout_opportunities,
|
||||
scout_claims,
|
||||
evolution,
|
||||
season_stats,
|
||||
)
|
||||
|
||||
|
||||
@ -105,6 +107,8 @@ app.include_router(stratplays.router)
|
||||
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")
|
||||
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
7
app/models/season_stats.py
Normal file
7
app/models/season_stats.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Season stats ORM models.
|
||||
|
||||
Models are defined in db_engine alongside all other Peewee models; this
|
||||
module re-exports them so callers can import from `app.models.season_stats`.
|
||||
"""
|
||||
|
||||
from ..db_engine import BattingSeasonStats, PitchingSeasonStats # noqa: F401
|
||||
@ -6,12 +6,9 @@ from pandas import DataFrame
|
||||
|
||||
from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
from ..services.evolution_init import _determine_card_type, initialize_card_evolution
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix='/api/v2/cards',
|
||||
tags=['cards']
|
||||
)
|
||||
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
|
||||
|
||||
|
||||
class CardPydantic(pydantic.BaseModel):
|
||||
@ -26,12 +23,20 @@ class CardModel(pydantic.BaseModel):
|
||||
cards: List[CardPydantic]
|
||||
|
||||
|
||||
@router.get('')
|
||||
@router.get("")
|
||||
async def get_cards(
|
||||
player_id: Optional[int] = None, team_id: Optional[int] = None, pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None, min_value: Optional[int] = None, max_value: Optional[int] = None, variant: Optional[int] = None,
|
||||
order_by: Optional[str] = None, limit: Optional[int] = None, dupes: Optional[bool] = None,
|
||||
csv: Optional[bool] = None):
|
||||
player_id: Optional[int] = None,
|
||||
team_id: Optional[int] = None,
|
||||
pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None,
|
||||
min_value: Optional[int] = None,
|
||||
max_value: Optional[int] = None,
|
||||
variant: Optional[int] = None,
|
||||
order_by: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
dupes: Optional[bool] = None,
|
||||
csv: Optional[bool] = None,
|
||||
):
|
||||
all_cards = Card.select()
|
||||
|
||||
# if all_cards.count() == 0:
|
||||
@ -65,7 +70,7 @@ async def get_cards(
|
||||
if max_value is not None:
|
||||
all_cards = all_cards.where(Card.value <= max_value)
|
||||
if order_by is not None:
|
||||
if order_by.lower() == 'new':
|
||||
if order_by.lower() == "new":
|
||||
all_cards = all_cards.order_by(-Card.id)
|
||||
else:
|
||||
all_cards = all_cards.order_by(Card.id)
|
||||
@ -73,8 +78,10 @@ async def get_cards(
|
||||
all_cards = all_cards.limit(limit)
|
||||
if dupes:
|
||||
if team_id is None:
|
||||
raise HTTPException(status_code=400, detail='Dupe checking must include a team_id')
|
||||
logging.debug(f'dupe check')
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Dupe checking must include a team_id"
|
||||
)
|
||||
logging.debug("dupe check")
|
||||
p_query = Card.select(Card.player).where(Card.team_id == team_id)
|
||||
seen = set()
|
||||
dupes = []
|
||||
@ -90,38 +97,52 @@ async def get_cards(
|
||||
# raise HTTPException(status_code=404, detail=f'No cards found')
|
||||
|
||||
if csv:
|
||||
data_list = [['id', 'player', 'cardset', 'rarity', 'team', 'pack', 'value']] #, 'variant']]
|
||||
data_list = [
|
||||
["id", "player", "cardset", "rarity", "team", "pack", "value"]
|
||||
] # , 'variant']]
|
||||
for line in all_cards:
|
||||
data_list.append(
|
||||
[
|
||||
line.id, line.player.p_name, line.player.cardset, line.player.rarity, line.team.abbrev, line.pack,
|
||||
line.id,
|
||||
line.player.p_name,
|
||||
line.player.cardset,
|
||||
line.player.rarity,
|
||||
line.team.abbrev,
|
||||
line.pack,
|
||||
line.value, # line.variant
|
||||
]
|
||||
)
|
||||
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
||||
|
||||
return Response(content=return_val, media_type='text/csv')
|
||||
return Response(content=return_val, media_type="text/csv")
|
||||
|
||||
else:
|
||||
return_val = {'count': all_cards.count(), 'cards': []}
|
||||
for x in all_cards:
|
||||
card_list = list(all_cards)
|
||||
player_ids = [c.player_id for c in card_list if c.player_id is not None]
|
||||
dex_by_player = {}
|
||||
if player_ids:
|
||||
for row in Paperdex.select().where(Paperdex.player_id << player_ids):
|
||||
dex_by_player.setdefault(row.player_id, []).append(row)
|
||||
return_val = {"count": len(card_list), "cards": []}
|
||||
for x in card_list:
|
||||
|
||||
this_record = model_to_dict(x)
|
||||
logging.debug(f'this_record: {this_record}')
|
||||
logging.debug(f"this_record: {this_record}")
|
||||
|
||||
this_dex = Paperdex.select().where(Paperdex.player == x)
|
||||
this_record['player']['paperdex'] = {'count': this_dex.count(), 'paperdex': []}
|
||||
for y in this_dex:
|
||||
this_record['player']['paperdex']['paperdex'].append(model_to_dict(y, recurse=False))
|
||||
entries = dex_by_player.get(x.player_id, [])
|
||||
this_record["player"]["paperdex"] = {
|
||||
"count": len(entries),
|
||||
"paperdex": [model_to_dict(y, recurse=False) for y in entries],
|
||||
}
|
||||
|
||||
return_val['cards'].append(this_record)
|
||||
return_val["cards"].append(this_record)
|
||||
|
||||
# return_val['cards'].append(model_to_dict(x))
|
||||
|
||||
return return_val
|
||||
|
||||
|
||||
@router.get('/{card_id}')
|
||||
@router.get("/{card_id}")
|
||||
async def v1_cards_get_one(card_id, csv: Optional[bool] = False):
|
||||
try:
|
||||
this_card = Card.get_by_id(card_id)
|
||||
@ -130,34 +151,37 @@ async def v1_cards_get_one(card_id, csv: Optional[bool] = False):
|
||||
|
||||
if csv:
|
||||
data_list = [
|
||||
['id', 'player', 'team', 'pack', 'value'],
|
||||
[this_card.id, this_card.player, this_card.team.abbrev, this_card.pack, this_card.value]
|
||||
["id", "player", "team", "pack", "value"],
|
||||
[
|
||||
this_card.id,
|
||||
this_card.player,
|
||||
this_card.team.abbrev,
|
||||
this_card.pack,
|
||||
this_card.value,
|
||||
],
|
||||
]
|
||||
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
||||
|
||||
return Response(content=return_val, media_type='text/csv')
|
||||
return Response(content=return_val, media_type="text/csv")
|
||||
|
||||
else:
|
||||
return_val = model_to_dict(this_card)
|
||||
return return_val
|
||||
|
||||
|
||||
@router.post('')
|
||||
@router.post("")
|
||||
async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to post cards. This event has been logged.'
|
||||
detail="You are not authorized to post cards. This event has been logged.",
|
||||
)
|
||||
last_card = Card.select(Card.id).order_by(-Card.id).limit(1)
|
||||
lc_id = last_card[0].id
|
||||
|
||||
new_cards = []
|
||||
player_ids = []
|
||||
inc_dex = True
|
||||
this_team = Team.get_by_id(cards.cards[0].team_id)
|
||||
if this_team.is_ai or 'Gauntlet' in this_team.abbrev:
|
||||
if this_team.is_ai or "Gauntlet" in this_team.abbrev:
|
||||
inc_dex = False
|
||||
|
||||
# new_dex = []
|
||||
@ -177,11 +201,28 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
|
||||
with db.atomic():
|
||||
Card.bulk_create(new_cards, batch_size=15)
|
||||
cost_query = Player.update(cost=Player.cost + 1).where(Player.player_id << player_ids)
|
||||
cost_query = Player.update(cost=Player.cost + 1).where(
|
||||
Player.player_id << player_ids
|
||||
)
|
||||
cost_query.execute()
|
||||
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
|
||||
|
||||
raise HTTPException(status_code=200, detail=f'{len(new_cards)} cards have been added')
|
||||
# WP-10: initialize evolution state for each new card (fire-and-forget)
|
||||
for x in cards.cards:
|
||||
try:
|
||||
this_player = Player.get_by_id(x.player_id)
|
||||
card_type = _determine_card_type(this_player)
|
||||
initialize_card_evolution(x.player_id, x.team_id, card_type)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"evolution hook: unexpected error for player_id=%s team_id=%s",
|
||||
x.player_id,
|
||||
x.team_id,
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=200, detail=f"{len(new_cards)} cards have been added"
|
||||
)
|
||||
|
||||
|
||||
# @router.post('/ai-update')
|
||||
@ -198,21 +239,27 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
# raise HTTPException(status_code=200, detail=f'Just sent AI cards to sheets')
|
||||
|
||||
|
||||
@router.post('/legal-check/{rarity_name}')
|
||||
@router.post("/legal-check/{rarity_name}")
|
||||
async def v1_cards_legal_check(
|
||||
rarity_name: str, card_id: list = Query(default=None), token: str = Depends(oauth2_scheme)):
|
||||
rarity_name: str,
|
||||
card_id: list = Query(default=None),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='Unauthorized'
|
||||
)
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if rarity_name not in CARDSETS.keys():
|
||||
return f'Rarity name {rarity_name} not a valid check'
|
||||
return f"Rarity name {rarity_name} not a valid check"
|
||||
|
||||
# Handle case where card_id is passed as a stringified list
|
||||
if card_id and len(card_id) == 1 and isinstance(card_id[0], str) and card_id[0].startswith('['):
|
||||
if (
|
||||
card_id
|
||||
and len(card_id) == 1
|
||||
and isinstance(card_id[0], str)
|
||||
and card_id[0].startswith("[")
|
||||
):
|
||||
import ast
|
||||
|
||||
try:
|
||||
card_id = [int(x) for x in ast.literal_eval(card_id[0])]
|
||||
except (ValueError, SyntaxError):
|
||||
@ -222,70 +269,81 @@ async def v1_cards_legal_check(
|
||||
all_cards = Card.select().where(Card.id << card_id)
|
||||
|
||||
for x in all_cards:
|
||||
if x.player.cardset_id not in CARDSETS[rarity_name]['human']:
|
||||
if x.player.cardset_id not in CARDSETS[rarity_name]["human"]:
|
||||
if x.player.p_name in x.player.description:
|
||||
bad_cards.append(x.player.description)
|
||||
else:
|
||||
bad_cards.append(f'{x.player.description} {x.player.p_name}')
|
||||
bad_cards.append(f"{x.player.description} {x.player.p_name}")
|
||||
|
||||
return {'count': len(bad_cards), 'bad_cards': bad_cards}
|
||||
return {"count": len(bad_cards), "bad_cards": bad_cards}
|
||||
|
||||
|
||||
@router.post('/post-update/{starting_id}')
|
||||
@router.post("/post-update/{starting_id}")
|
||||
async def v1_cards_post_update(starting_id: int, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to update card lists. This event has been logged.'
|
||||
detail="You are not authorized to update card lists. This event has been logged.",
|
||||
)
|
||||
|
||||
# sheets.post_new_cards(SHEETS_AUTH, starting_id)
|
||||
raise HTTPException(status_code=200, detail=f'Just sent cards to sheets starting at ID {starting_id}')
|
||||
raise HTTPException(
|
||||
status_code=200,
|
||||
detail=f"Just sent cards to sheets starting at ID {starting_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.post('/post-delete')
|
||||
@router.post("/post-delete")
|
||||
async def v1_cards_post_delete(del_ids: str, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to delete card lists. This event has been logged.'
|
||||
detail="You are not authorized to delete card lists. This event has been logged.",
|
||||
)
|
||||
|
||||
logging.info(f'del_ids: {del_ids} / type: {type(del_ids)}')
|
||||
logging.info(f"del_ids: {del_ids} / type: {type(del_ids)}")
|
||||
# sheets.post_deletion(SHEETS_AUTH, del_ids.split(','))
|
||||
|
||||
|
||||
@router.post('/wipe-team/{team_id}')
|
||||
@router.post("/wipe-team/{team_id}")
|
||||
async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to wipe teams. This event has been logged.'
|
||||
detail="You are not authorized to wipe teams. This event has been logged.",
|
||||
)
|
||||
|
||||
try:
|
||||
this_team = Team.get_by_id(team_id)
|
||||
except DoesNotExist as e:
|
||||
except DoesNotExist:
|
||||
logging.error(f'/cards/wipe-team/{team_id} - could not find team')
|
||||
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')
|
||||
|
||||
t_query = Card.update(team=None).where(Card.team == this_team).execute()
|
||||
return f'Wiped {t_query} cards'
|
||||
return f"Wiped {t_query} cards"
|
||||
|
||||
|
||||
@router.patch('/{card_id}')
|
||||
@router.patch("/{card_id}")
|
||||
async def v1_cards_patch(
|
||||
card_id, player_id: Optional[int] = None, team_id: Optional[int] = None, pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None, variant: Optional[int] = None, roster1_id: Optional[int] = None, roster2_id: Optional[int] = None,
|
||||
roster3_id: Optional[int] = None, token: str = Depends(oauth2_scheme)):
|
||||
card_id,
|
||||
player_id: Optional[int] = None,
|
||||
team_id: Optional[int] = None,
|
||||
pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None,
|
||||
variant: Optional[int] = None,
|
||||
roster1_id: Optional[int] = None,
|
||||
roster2_id: Optional[int] = None,
|
||||
roster3_id: Optional[int] = None,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to patch cards. This event has been logged.'
|
||||
detail="You are not authorized to patch cards. This event has been logged.",
|
||||
)
|
||||
try:
|
||||
this_card = Card.get_by_id(card_id)
|
||||
@ -318,17 +376,17 @@ async def v1_cards_patch(
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=418,
|
||||
detail='Well slap my ass and call me a teapot; I could not save that rarity'
|
||||
detail="Well slap my ass and call me a teapot; I could not save that rarity",
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{card_id}')
|
||||
@router.delete("/{card_id}")
|
||||
async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to delete packs. This event has been logged.'
|
||||
detail="You are not authorized to delete packs. This event has been logged.",
|
||||
)
|
||||
try:
|
||||
this_card = Card.get_by_id(card_id)
|
||||
@ -338,6 +396,6 @@ async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)):
|
||||
count = this_card.delete_instance()
|
||||
|
||||
if count == 1:
|
||||
raise HTTPException(status_code=200, detail=f'Card {card_id} has been deleted')
|
||||
raise HTTPException(status_code=200, detail=f"Card {card_id} has been deleted")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f'Card {card_id} was not deleted')
|
||||
raise HTTPException(status_code=500, detail=f"Card {card_id} was not deleted")
|
||||
|
||||
231
app/routers_v2/evolution.py
Normal file
231
app/routers_v2/evolution.py
Normal file
@ -0,0 +1,231 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
import logging
|
||||
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"])
|
||||
|
||||
# Tier -> threshold attribute name. Index = current_tier; value is the
|
||||
# attribute on EvolutionTrack whose value is the *next* threshold to reach.
|
||||
# Tier 4 is fully evolved so there is no next threshold (None sentinel).
|
||||
_NEXT_THRESHOLD_ATTR = {
|
||||
0: "t1_threshold",
|
||||
1: "t2_threshold",
|
||||
2: "t3_threshold",
|
||||
3: "t4_threshold",
|
||||
4: None,
|
||||
}
|
||||
|
||||
|
||||
def _build_card_state_response(state) -> dict:
|
||||
"""Serialise an EvolutionCardState into the standard API response shape.
|
||||
|
||||
Produces a flat dict with player_id and team_id as plain integers,
|
||||
a nested 'track' dict with all threshold fields, and a computed
|
||||
'next_threshold' field:
|
||||
- For tiers 0-3: the threshold value for the tier immediately above.
|
||||
- For tier 4 (fully evolved): None.
|
||||
|
||||
Uses model_to_dict(recurse=False) internally so FK fields are returned
|
||||
as IDs rather than nested objects, then promotes the needed IDs up to
|
||||
the top level.
|
||||
"""
|
||||
track = state.track
|
||||
track_dict = model_to_dict(track, recurse=False)
|
||||
|
||||
next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier)
|
||||
next_threshold = getattr(track, next_attr) if next_attr else None
|
||||
|
||||
return {
|
||||
"player_id": state.player_id,
|
||||
"team_id": state.team_id,
|
||||
"current_tier": state.current_tier,
|
||||
"current_value": state.current_value,
|
||||
"fully_evolved": state.fully_evolved,
|
||||
"last_evaluated_at": (
|
||||
state.last_evaluated_at.isoformat() if state.last_evaluated_at else None
|
||||
),
|
||||
"track": track_dict,
|
||||
"next_threshold": next_threshold,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tracks")
|
||||
async def list_tracks(
|
||||
card_type: Optional[str] = Query(default=None),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import EvolutionTrack
|
||||
|
||||
query = EvolutionTrack.select()
|
||||
if card_type is not None:
|
||||
query = query.where(EvolutionTrack.card_type == card_type)
|
||||
|
||||
items = [model_to_dict(t, recurse=False) for t in query]
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
@router.get("/tracks/{track_id}")
|
||||
async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import EvolutionTrack
|
||||
|
||||
try:
|
||||
track = EvolutionTrack.get_by_id(track_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail=f"Track {track_id} not found")
|
||||
|
||||
return model_to_dict(track, recurse=False)
|
||||
|
||||
|
||||
@router.get("/cards/{card_id}")
|
||||
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
|
||||
"""Return the EvolutionCardState for a card identified by its Card.id.
|
||||
|
||||
Resolves card_id -> (player_id, team_id) via the Card table, then looks
|
||||
up the matching EvolutionCardState row. Because duplicate cards for the
|
||||
same player+team share one state row (unique-(player,team) constraint),
|
||||
any card_id belonging to that player on that team returns the same state.
|
||||
|
||||
Returns 404 when:
|
||||
- The card_id does not exist in the Card table.
|
||||
- The card exists but has no corresponding EvolutionCardState yet.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import Card, EvolutionCardState, EvolutionTrack, DoesNotExist
|
||||
|
||||
# Resolve card_id to player+team
|
||||
try:
|
||||
card = Card.get_by_id(card_id)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=404, detail=f"Card {card_id} not found")
|
||||
|
||||
# Look up the evolution state for this (player, team) pair, joining the
|
||||
# track so a single query resolves both rows.
|
||||
try:
|
||||
state = (
|
||||
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
|
||||
.join(EvolutionTrack)
|
||||
.where(
|
||||
(EvolutionCardState.player == card.player_id)
|
||||
& (EvolutionCardState.team == card.team_id)
|
||||
)
|
||||
.get()
|
||||
)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No evolution state for card {card_id}",
|
||||
)
|
||||
|
||||
return _build_card_state_response(state)
|
||||
|
||||
|
||||
@router.post("/cards/{card_id}/evaluate")
|
||||
async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)):
|
||||
"""Force-recalculate evolution state for a card from career stats.
|
||||
|
||||
Resolves card_id to (player_id, team_id), then recomputes the evolution
|
||||
tier from all player_season_stats rows for that pair. Idempotent.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import Card
|
||||
from ..services.evolution_evaluator import evaluate_card as _evaluate
|
||||
|
||||
try:
|
||||
card = Card.get_by_id(card_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail=f"Card {card_id} not found")
|
||||
|
||||
try:
|
||||
result = _evaluate(card.player_id, card.team_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@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. Pairs without a state row are silently skipped. Per-player errors are
|
||||
logged but do not abort the batch.
|
||||
"""
|
||||
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
|
||||
|
||||
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:
|
||||
try:
|
||||
state = EvolutionCardState.get_or_none(
|
||||
(EvolutionCardState.player_id == player_id)
|
||||
& (EvolutionCardState.team_id == team_id)
|
||||
)
|
||||
if state is None:
|
||||
continue
|
||||
|
||||
old_tier = state.current_tier
|
||||
result = evaluate_card(player_id, team_id)
|
||||
evaluated += 1
|
||||
|
||||
new_tier = result.get("current_tier", old_tier)
|
||||
if new_tier > old_tier:
|
||||
player_name = "Unknown"
|
||||
try:
|
||||
p = Player.get_by_id(player_id)
|
||||
player_name = p.p_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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.get("current_value", 0),
|
||||
"track_name": state.track.name if state.track else "Unknown",
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Evolution eval failed for player={player_id} team={team_id}: {exc}"
|
||||
)
|
||||
|
||||
return {"evaluated": evaluated, "tier_ups": tier_ups}
|
||||
@ -36,14 +36,3 @@ async def get_player_keys(player_id: list = Query(default=None)):
|
||||
|
||||
return_val = {"count": len(all_keys), "keys": [dict(x) for x in all_keys]}
|
||||
return return_val
|
||||
|
||||
|
||||
@router.post("/live-update/pitching")
|
||||
def live_update_pitching(files: BattingFiles, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401, detail="You are not authorized to initiate live updates."
|
||||
)
|
||||
|
||||
return files.dict()
|
||||
|
||||
72
app/routers_v2/season_stats.py
Normal file
72
app/routers_v2/season_stats.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Season stats API endpoints.
|
||||
|
||||
Covers WP-13 (Post-Game Callback Integration):
|
||||
POST /api/v2/season-stats/update-game/{game_id}
|
||||
|
||||
Delegates to app.services.season_stats.update_season_stats() which
|
||||
recomputes full-season stats from all StratPlay and Decision rows for
|
||||
every player who appeared in the game, then writes those totals into
|
||||
batting_season_stats and pitching_season_stats.
|
||||
|
||||
Idempotency is enforced by the service layer: re-delivery of the same
|
||||
game_id returns {"updated": 0, "skipped": true} without modifying stats.
|
||||
Pass force=true to bypass the idempotency guard and force recalculation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
|
||||
router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/update-game/{game_id}")
|
||||
async def update_game_season_stats(
|
||||
game_id: int, force: bool = False, token: str = Depends(oauth2_scheme)
|
||||
):
|
||||
"""Recalculate season stats from all StratPlay and Decision rows for a game.
|
||||
|
||||
Calls update_season_stats(game_id, force=force) from the service layer which:
|
||||
- Recomputes full-season totals from all StratPlay rows for each player
|
||||
- Aggregates Decision rows for pitching win/loss/save/hold stats
|
||||
- Writes totals into batting_season_stats and pitching_season_stats
|
||||
- Guards against redundant work via the ProcessedGame ledger
|
||||
|
||||
Query params:
|
||||
- force: if true, bypasses the idempotency guard and reprocesses a
|
||||
previously seen game_id (useful for correcting stats after data fixes)
|
||||
|
||||
Response: {"updated": N, "skipped": false}
|
||||
- N: total player_season_stats rows upserted (batters + pitchers)
|
||||
- skipped: true when this game_id was already processed and force=false
|
||||
|
||||
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")
|
||||
|
||||
from ..services.season_stats import update_season_stats
|
||||
from ..db_engine import DoesNotExist
|
||||
|
||||
try:
|
||||
result = update_season_stats(game_id, force=force)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=404, detail=f"Game {game_id} not found")
|
||||
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}",
|
||||
)
|
||||
|
||||
updated = result.get("batters_updated", 0) + result.get("pitchers_updated", 0)
|
||||
return {
|
||||
"updated": updated,
|
||||
"skipped": result.get("skipped", False),
|
||||
}
|
||||
@ -135,15 +135,15 @@ async def get_teams(
|
||||
if has_guide is not None:
|
||||
# Use boolean comparison (PostgreSQL-compatible)
|
||||
if not has_guide:
|
||||
all_teams = all_teams.where(Team.has_guide == False)
|
||||
all_teams = all_teams.where(Team.has_guide == False) # noqa: E712
|
||||
else:
|
||||
all_teams = all_teams.where(Team.has_guide == True)
|
||||
all_teams = all_teams.where(Team.has_guide == True) # noqa: E712
|
||||
|
||||
if is_ai is not None:
|
||||
if not is_ai:
|
||||
all_teams = all_teams.where(Team.is_ai == False)
|
||||
all_teams = all_teams.where(Team.is_ai == False) # noqa: E712
|
||||
else:
|
||||
all_teams = all_teams.where(Team.is_ai == True)
|
||||
all_teams = all_teams.where(Team.is_ai == True) # noqa: E712
|
||||
|
||||
if event_id is not None:
|
||||
all_teams = all_teams.where(Team.event_id == event_id)
|
||||
@ -254,24 +254,24 @@ def get_scouting_dfs(allowed_players, position: str):
|
||||
if position in ["LF", "CF", "RF"]:
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm OF"
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name="Arm OF"
|
||||
)
|
||||
)
|
||||
elif position == "C":
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm C"
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name="Arm C"
|
||||
)
|
||||
)
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.pb) for x in positions]), name=f"PB C"
|
||||
dict([(x.player.player_id, x.pb) for x in positions]), name="PB C"
|
||||
)
|
||||
)
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.overthrow) for x in positions]),
|
||||
name=f"Throw C",
|
||||
name="Throw C",
|
||||
)
|
||||
)
|
||||
|
||||
@ -314,11 +314,11 @@ async def get_team_lineup(
|
||||
all_players = Player.select().where(Player.franchise == this_team.sname)
|
||||
|
||||
if difficulty_name == "exhibition":
|
||||
logging.info(f"pulling an exhibition lineup")
|
||||
logging.info("pulling an exhibition lineup")
|
||||
if cardset_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Must provide at least one cardset_id for exhibition lineups",
|
||||
detail="Must provide at least one cardset_id for exhibition lineups",
|
||||
)
|
||||
legal_players = all_players.where(Player.cardset_id << cardset_id)
|
||||
|
||||
@ -404,17 +404,17 @@ async def get_team_lineup(
|
||||
# if x.battingcard.player.p_name not in player_names:
|
||||
# starting_nine['DH'] = x.battingcard.player
|
||||
# break
|
||||
logging.debug(f"Searching for a DH!")
|
||||
logging.debug("Searching for a DH!")
|
||||
dh_query = legal_players.order_by(Player.cost.desc())
|
||||
for x in dh_query:
|
||||
logging.debug(f"checking {x.p_name} for {position}")
|
||||
if x.p_name not in player_names and "P" not in x.pos_1:
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine["DH"]["player"] = model_to_dict(x)
|
||||
try:
|
||||
vl, vr, total_ops = get_bratings(x.player_id)
|
||||
except AttributeError as e:
|
||||
logging.debug(f"Could not find batting lines")
|
||||
except AttributeError:
|
||||
logging.debug("Could not find batting lines")
|
||||
else:
|
||||
# starting_nine[position]['vl'] = vl
|
||||
# starting_nine[position]['vr'] = vr
|
||||
@ -429,12 +429,12 @@ async def get_team_lineup(
|
||||
for x in dh_query:
|
||||
logging.debug(f"checking {x.p_name} for {position}")
|
||||
if x.p_name not in player_names:
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine["DH"]["player"] = model_to_dict(x)
|
||||
try:
|
||||
vl, vr, total_ops = get_bratings(x.player_id)
|
||||
except AttributeError as e:
|
||||
logging.debug(f"Could not find batting lines")
|
||||
except AttributeError:
|
||||
logging.debug("Could not find batting lines")
|
||||
else:
|
||||
vl, vr, total_ops = get_bratings(x.player_id)
|
||||
starting_nine[position]["vl"] = vl["obp"] + vl["slg"]
|
||||
@ -464,7 +464,7 @@ async def get_team_lineup(
|
||||
x.player.p_name not in player_names
|
||||
and x.player.p_name.lower() != pitcher_name
|
||||
):
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine[position]["player"] = model_to_dict(x.player)
|
||||
vl, vr, total_ops = get_bratings(x.player.player_id)
|
||||
starting_nine[position]["vl"] = vl
|
||||
@ -542,7 +542,7 @@ async def get_team_lineup(
|
||||
x.player.p_name not in player_names
|
||||
and x.player.p_name.lower() != pitcher_name
|
||||
):
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine[position]["player"] = model_to_dict(x.player)
|
||||
vl, vr, total_ops = get_bratings(x.player.player_id)
|
||||
starting_nine[position]["vl"] = vl["obp"] + vl["slg"]
|
||||
@ -617,8 +617,9 @@ def sort_pitchers(pitching_card_query) -> DataFrame | None:
|
||||
return float("inf")
|
||||
ops_vl = vlval.obp + vlval.slg
|
||||
ops_vr = vrval.obp + vrval.slg
|
||||
# TODO: should this be max??
|
||||
return (ops_vr + ops_vl + min(ops_vl, ops_vr)) / 3
|
||||
# Weight the weaker split (higher OPS allowed) so platoon weaknesses are penalized.
|
||||
# Starters face both LHH and RHH, so vulnerability against either hand matters.
|
||||
return (ops_vr + ops_vl + max(ops_vl, ops_vr)) / 3
|
||||
|
||||
pitcher_df["total_ops"] = pitcher_df.apply(get_total_ops, axis=1)
|
||||
return pitcher_df.sort_values(by="total_ops")
|
||||
@ -648,11 +649,11 @@ async def get_team_sp(
|
||||
all_players = Player.select().where(Player.franchise == this_team.sname)
|
||||
|
||||
if difficulty_name == "exhibition":
|
||||
logging.info(f"pulling an exhibition lineup")
|
||||
logging.info("pulling an exhibition lineup")
|
||||
if cardset_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Must provide at least one cardset_id for exhibition lineups",
|
||||
detail="Must provide at least one cardset_id for exhibition lineups",
|
||||
)
|
||||
legal_players = all_players.where(Player.cardset_id << cardset_id)
|
||||
|
||||
@ -699,7 +700,8 @@ async def get_team_sp(
|
||||
return float("inf")
|
||||
ops_vl = vlval.obp + vlval.slg
|
||||
ops_vr = vrval.obp + vrval.slg
|
||||
return (ops_vr + ops_vl + min(ops_vl, ops_vr)) / 3
|
||||
# Weight the weaker split (higher OPS allowed) so platoon weaknesses are penalized.
|
||||
return (ops_vr + ops_vl + max(ops_vl, ops_vr)) / 3
|
||||
|
||||
starter_df["total_ops"] = starter_df.apply(get_total_ops, axis=1)
|
||||
return starter_df.sort_values(by="total_ops")
|
||||
@ -776,11 +778,11 @@ async def get_team_rp(
|
||||
)
|
||||
|
||||
if difficulty_name == "exhibition":
|
||||
logging.info(f"pulling an exhibition RP")
|
||||
logging.info("pulling an exhibition RP")
|
||||
if cardset_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Must provide at least one cardset_id for exhibition lineups",
|
||||
detail="Must provide at least one cardset_id for exhibition lineups",
|
||||
)
|
||||
legal_players = all_players.where(Player.cardset_id << cardset_id)
|
||||
|
||||
@ -932,7 +934,7 @@ async def get_team_rp(
|
||||
)
|
||||
return this_player
|
||||
|
||||
logging.info(f"Falling to last chance pitcher")
|
||||
logging.info("Falling to last chance pitcher")
|
||||
all_relievers = sort_pitchers(
|
||||
PitchingCard.select()
|
||||
.join(Player)
|
||||
@ -955,7 +957,7 @@ async def get_team_record(team_id: int, season: int):
|
||||
all_games = StratGame.select().where(
|
||||
((StratGame.away_team_id == team_id) | (StratGame.home_team_id == team_id))
|
||||
& (StratGame.season == season)
|
||||
& (StratGame.short_game == False)
|
||||
& (StratGame.short_game == False) # noqa: E712
|
||||
)
|
||||
|
||||
template = {
|
||||
@ -1047,9 +1049,6 @@ async def team_buy_players(team_id: int, ids: str, ts: str):
|
||||
detail=f"You are not authorized to buy {this_team.abbrev} cards. This event has been logged.",
|
||||
)
|
||||
|
||||
last_card = Card.select(Card.id).order_by(-Card.id).limit(1)
|
||||
lc_id = last_card[0].id
|
||||
|
||||
all_ids = ids.split(",")
|
||||
conf_message = ""
|
||||
total_cost = 0
|
||||
@ -1096,7 +1095,7 @@ async def team_buy_players(team_id: int, ids: str, ts: str):
|
||||
if this_player.rarity.value >= 2:
|
||||
new_notif = Notification(
|
||||
created=datetime.now(),
|
||||
title=f"Price Change",
|
||||
title="Price Change",
|
||||
desc="Modified by buying and selling",
|
||||
field_name=f"{this_player.description} "
|
||||
f"{this_player.p_name if this_player.p_name not in this_player.description else ''}",
|
||||
@ -1240,7 +1239,7 @@ async def team_sell_cards(team_id: int, ids: str, ts: str):
|
||||
if this_player.rarity.value >= 2:
|
||||
new_notif = Notification(
|
||||
created=datetime.now(),
|
||||
title=f"Price Change",
|
||||
title="Price Change",
|
||||
desc="Modified by buying and selling",
|
||||
field_name=f"{this_player.description} "
|
||||
f"{this_player.p_name if this_player.p_name not in this_player.description else ''}",
|
||||
@ -1291,7 +1290,7 @@ async def get_team_cards(team_id, csv: Optional[bool] = True):
|
||||
.order_by(-Card.player.rarity.value, Card.player.p_name)
|
||||
)
|
||||
if all_cards.count() == 0:
|
||||
raise HTTPException(status_code=404, detail=f"No cards found")
|
||||
raise HTTPException(status_code=404, detail="No cards found")
|
||||
|
||||
card_vals = [model_to_dict(x) for x in all_cards]
|
||||
|
||||
@ -1389,7 +1388,7 @@ async def team_season_update(new_season: int, token: str = Depends(oauth2_scheme
|
||||
detail="You are not authorized to post teams. This event has been logged.",
|
||||
)
|
||||
|
||||
r_query = Team.update(
|
||||
Team.update(
|
||||
ranking=1000, season=new_season, wallet=Team.wallet + 250, has_guide=False
|
||||
).execute()
|
||||
current = Current.latest()
|
||||
@ -1529,3 +1528,69 @@ async def delete_team(team_id, token: str = Depends(oauth2_scheme)):
|
||||
raise HTTPException(status_code=200, detail=f"Team {team_id} has been deleted")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Team {team_id} was not deleted")
|
||||
|
||||
|
||||
@router.get("/{team_id}/evolutions")
|
||||
async def list_team_evolutions(
|
||||
team_id: int,
|
||||
card_type: Optional[str] = Query(default=None),
|
||||
tier: Optional[int] = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=10, ge=1, le=100),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
"""List all EvolutionCardState rows for a team, with optional filters.
|
||||
|
||||
Joins EvolutionCardState → EvolutionTrack (for card_type filtering and
|
||||
threshold context) and EvolutionCardState → Player (for player_name),
|
||||
both eager-loaded in a single query. Results are paginated via
|
||||
page/per_page (1-indexed pages); items are ordered by current_tier DESC,
|
||||
current_value DESC so the most-progressed cards appear first.
|
||||
|
||||
Query parameters:
|
||||
card_type -- filter to states whose track.card_type matches (e.g. 'batter', 'sp')
|
||||
tier -- filter to states at a specific current_tier (0-4)
|
||||
page -- 1-indexed page number (default 1)
|
||||
per_page -- items per page (default 10, max 100)
|
||||
|
||||
Response shape:
|
||||
{"count": N, "items": [card_state_with_threshold_context, ...]}
|
||||
|
||||
Each item in 'items' has the same shape as GET /evolution/cards/{card_id},
|
||||
plus a ``player_name`` field sourced from the Player table.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import EvolutionCardState, EvolutionTrack, Player
|
||||
from ..routers_v2.evolution import _build_card_state_response
|
||||
|
||||
query = (
|
||||
EvolutionCardState.select(EvolutionCardState, EvolutionTrack, Player)
|
||||
.join(EvolutionTrack)
|
||||
.switch(EvolutionCardState)
|
||||
.join(Player)
|
||||
.where(EvolutionCardState.team == team_id)
|
||||
.order_by(
|
||||
EvolutionCardState.current_tier.desc(),
|
||||
EvolutionCardState.current_value.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
if card_type is not None:
|
||||
query = query.where(EvolutionTrack.card_type == card_type)
|
||||
|
||||
if tier is not None:
|
||||
query = query.where(EvolutionCardState.current_tier == tier)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * per_page
|
||||
page_query = query.offset(offset).limit(per_page)
|
||||
|
||||
items = []
|
||||
for state in page_query:
|
||||
item = _build_card_state_response(state)
|
||||
item["player_name"] = state.player.p_name
|
||||
items.append(item)
|
||||
return {"count": total, "items": items}
|
||||
|
||||
0
app/seed/__init__.py
Normal file
0
app/seed/__init__.py
Normal file
29
app/seed/evolution_tracks.json
Normal file
29
app/seed/evolution_tracks.json
Normal file
@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"name": "Batter Track",
|
||||
"card_type": "batter",
|
||||
"formula": "pa + tb * 2",
|
||||
"t1_threshold": 37,
|
||||
"t2_threshold": 149,
|
||||
"t3_threshold": 448,
|
||||
"t4_threshold": 896
|
||||
},
|
||||
{
|
||||
"name": "Starting Pitcher Track",
|
||||
"card_type": "sp",
|
||||
"formula": "ip + k",
|
||||
"t1_threshold": 10,
|
||||
"t2_threshold": 40,
|
||||
"t3_threshold": 120,
|
||||
"t4_threshold": 240
|
||||
},
|
||||
{
|
||||
"name": "Relief Pitcher Track",
|
||||
"card_type": "rp",
|
||||
"formula": "ip + k",
|
||||
"t1_threshold": 3,
|
||||
"t2_threshold": 12,
|
||||
"t3_threshold": 35,
|
||||
"t4_threshold": 70
|
||||
}
|
||||
]
|
||||
66
app/seed/evolution_tracks.py
Normal file
66
app/seed/evolution_tracks.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Seed script for EvolutionTrack records.
|
||||
|
||||
Loads track definitions from evolution_tracks.json and upserts them into the
|
||||
database using get_or_create keyed on name. Existing tracks have their
|
||||
thresholds and formula updated to match the JSON in case values have changed.
|
||||
|
||||
Can be run standalone:
|
||||
python -m app.seed.evolution_tracks
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.db_engine import EvolutionTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JSON_PATH = Path(__file__).parent / "evolution_tracks.json"
|
||||
|
||||
|
||||
def seed_evolution_tracks() -> list[EvolutionTrack]:
|
||||
"""Upsert evolution tracks from JSON seed data.
|
||||
|
||||
Returns a list of EvolutionTrack instances that were created or updated.
|
||||
"""
|
||||
raw = _JSON_PATH.read_text(encoding="utf-8")
|
||||
track_defs = json.loads(raw)
|
||||
|
||||
results: list[EvolutionTrack] = []
|
||||
|
||||
for defn in track_defs:
|
||||
track, created = EvolutionTrack.get_or_create(
|
||||
name=defn["name"],
|
||||
defaults={
|
||||
"card_type": defn["card_type"],
|
||||
"formula": defn["formula"],
|
||||
"t1_threshold": defn["t1_threshold"],
|
||||
"t2_threshold": defn["t2_threshold"],
|
||||
"t3_threshold": defn["t3_threshold"],
|
||||
"t4_threshold": defn["t4_threshold"],
|
||||
},
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update mutable fields in case the JSON values changed.
|
||||
track.card_type = defn["card_type"]
|
||||
track.formula = defn["formula"]
|
||||
track.t1_threshold = defn["t1_threshold"]
|
||||
track.t2_threshold = defn["t2_threshold"]
|
||||
track.t3_threshold = defn["t3_threshold"]
|
||||
track.t4_threshold = defn["t4_threshold"]
|
||||
track.save()
|
||||
|
||||
action = "created" if created else "updated"
|
||||
logger.info("[%s] %s (card_type=%s)", action, track.name, track.card_type)
|
||||
results.append(track)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Seeding evolution tracks...")
|
||||
tracks = seed_evolution_tracks()
|
||||
logger.info("Done. %d track(s) processed.", len(tracks))
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
196
app/services/evolution_evaluator.py
Normal file
196
app/services/evolution_evaluator.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""Evolution evaluator service (WP-08).
|
||||
|
||||
Force-recalculates a card's evolution state from career totals.
|
||||
|
||||
evaluate_card() is the main entry point:
|
||||
1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats 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-05 (EvolutionCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
|
||||
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, UTC
|
||||
import logging
|
||||
|
||||
|
||||
class _CareerTotals:
|
||||
"""Aggregated career stats for a (player_id, team_id) pair.
|
||||
|
||||
Passed to the formula engine as a stats-duck-type object with the attributes
|
||||
required by compute_value_for_track:
|
||||
batter: pa, hits, doubles, triples, hr
|
||||
sp/rp: outs, strikeouts
|
||||
"""
|
||||
|
||||
__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 BattingSeasonStats or PitchingSeasonStats rows (based on
|
||||
card_type) 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 BattingSeasonStats/PitchingSeasonStats
|
||||
(used in tests to inject a stub model with all stat fields).
|
||||
_state_model: Override for EvolutionCardState (used in tests to avoid
|
||||
importing from db_engine before WP-05 merges).
|
||||
_compute_value_fn: Override for formula_engine.compute_value_for_track
|
||||
(used in tests to avoid importing formula_engine before WP-09 merges).
|
||||
_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 _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 from the appropriate season stats table
|
||||
if _stats_model is not None:
|
||||
# Test override: use the injected stub model for all fields
|
||||
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),
|
||||
strikeouts=sum(r.strikeouts for r in rows),
|
||||
)
|
||||
else:
|
||||
from app.db_engine import (
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
) # noqa: PLC0415
|
||||
|
||||
card_type = card_state.track.card_type
|
||||
if card_type == "batter":
|
||||
rows = list(
|
||||
BattingSeasonStats.select().where(
|
||||
(BattingSeasonStats.player == player_id)
|
||||
& (BattingSeasonStats.team == 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=0,
|
||||
strikeouts=sum(r.strikeouts for r in rows),
|
||||
)
|
||||
else:
|
||||
rows = list(
|
||||
PitchingSeasonStats.select().where(
|
||||
(PitchingSeasonStats.player == player_id)
|
||||
& (PitchingSeasonStats.team == team_id)
|
||||
)
|
||||
)
|
||||
totals = _CareerTotals(
|
||||
pa=0,
|
||||
hits=0,
|
||||
doubles=0,
|
||||
triples=0,
|
||||
hr=0,
|
||||
outs=sum(r.outs for r in rows),
|
||||
strikeouts=sum(r.strikeouts 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.now(UTC)
|
||||
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(),
|
||||
}
|
||||
138
app/services/evolution_init.py
Normal file
138
app/services/evolution_init.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""
|
||||
WP-10: Pack opening hook — evolution_card_state initialization.
|
||||
|
||||
Public API
|
||||
----------
|
||||
initialize_card_evolution(player_id, team_id, card_type)
|
||||
Get-or-create an EvolutionCardState for the (player_id, team_id) pair.
|
||||
Returns the state instance on success, or None if initialization fails
|
||||
(missing track, integrity error, etc.). Never raises.
|
||||
|
||||
_determine_card_type(player)
|
||||
Pure function: inspect player.pos_1 and return 'sp', 'rp', or 'batter'.
|
||||
Exported so the cards router and tests can call it directly.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- The function is intentionally fire-and-forget from the caller's perspective.
|
||||
All exceptions are caught and logged; pack opening is never blocked.
|
||||
- No EvolutionProgress rows are created here. Progress accumulation is a
|
||||
separate concern handled by the stats-update pipeline (WP-07/WP-08).
|
||||
- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern);
|
||||
we do NOT replicate that exclusion here — all teams get an evolution state
|
||||
so that future rule changes don't require back-filling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _determine_card_type(player) -> str:
|
||||
"""Map a player's primary position to an evolution card_type string.
|
||||
|
||||
Rules (from WP-10 spec):
|
||||
- pos_1 contains 'SP' -> 'sp'
|
||||
- pos_1 contains 'RP' or 'CP' -> 'rp'
|
||||
- anything else -> 'batter'
|
||||
|
||||
Args:
|
||||
player: Any object with a ``pos_1`` attribute (Player model or stub).
|
||||
|
||||
Returns:
|
||||
One of the strings 'batter', 'sp', 'rp'.
|
||||
"""
|
||||
pos = (player.pos_1 or "").upper()
|
||||
if "SP" in pos:
|
||||
return "sp"
|
||||
if "RP" in pos or "CP" in pos:
|
||||
return "rp"
|
||||
return "batter"
|
||||
|
||||
|
||||
def initialize_card_evolution(
|
||||
player_id: int,
|
||||
team_id: int,
|
||||
card_type: str,
|
||||
) -> Optional[EvolutionCardState]:
|
||||
"""Get-or-create an EvolutionCardState for a newly acquired card.
|
||||
|
||||
Called by the cards POST endpoint after each card is inserted. The
|
||||
function is idempotent: if a state row already exists for the
|
||||
(player_id, team_id) pair it is returned unchanged — existing
|
||||
evolution progress is never reset.
|
||||
|
||||
Args:
|
||||
player_id: Primary key of the Player row (Player.player_id).
|
||||
team_id: Primary key of the Team row (Team.id).
|
||||
card_type: One of 'batter', 'sp', 'rp'. Determines which
|
||||
EvolutionTrack is assigned to the new state.
|
||||
|
||||
Returns:
|
||||
The existing or newly created EvolutionCardState instance, or
|
||||
None if initialization could not complete (missing track seed
|
||||
data, unexpected DB error, etc.).
|
||||
"""
|
||||
try:
|
||||
track = EvolutionTrack.get(EvolutionTrack.card_type == card_type)
|
||||
except DoesNotExist:
|
||||
logger.warning(
|
||||
"evolution_init: no EvolutionTrack found for card_type=%r "
|
||||
"(player_id=%s, team_id=%s) — skipping state creation",
|
||||
card_type,
|
||||
player_id,
|
||||
team_id,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"evolution_init: unexpected error fetching track "
|
||||
"(card_type=%r, player_id=%s, team_id=%s)",
|
||||
card_type,
|
||||
player_id,
|
||||
team_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
state, created = EvolutionCardState.get_or_create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
defaults={
|
||||
"track": track,
|
||||
"current_tier": 0,
|
||||
"current_value": 0.0,
|
||||
"fully_evolved": False,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
logger.debug(
|
||||
"evolution_init: created EvolutionCardState id=%s "
|
||||
"(player_id=%s, team_id=%s, card_type=%r)",
|
||||
state.id,
|
||||
player_id,
|
||||
team_id,
|
||||
card_type,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"evolution_init: state already exists id=%s "
|
||||
"(player_id=%s, team_id=%s) — no-op",
|
||||
state.id,
|
||||
player_id,
|
||||
team_id,
|
||||
)
|
||||
return state
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"evolution_init: failed to get_or_create state "
|
||||
"(player_id=%s, team_id=%s, card_type=%r)",
|
||||
player_id,
|
||||
team_id,
|
||||
card_type,
|
||||
)
|
||||
return None
|
||||
119
app/services/formula_engine.py
Normal file
119
app/services/formula_engine.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Formula engine for evolution value computation (WP-09).
|
||||
|
||||
Three pure functions that compute a numeric evolution value from career stats,
|
||||
plus helpers for formula dispatch and tier classification.
|
||||
|
||||
Stats attributes expected by each formula:
|
||||
compute_batter_value: pa, hits, doubles, triples, hr (from BattingSeasonStats)
|
||||
compute_sp_value: outs, strikeouts (from PitchingSeasonStats)
|
||||
compute_rp_value: outs, strikeouts (from PitchingSeasonStats)
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class BatterStats(Protocol):
|
||||
pa: int
|
||||
hits: int
|
||||
doubles: int
|
||||
triples: int
|
||||
hr: int
|
||||
|
||||
|
||||
class PitcherStats(Protocol):
|
||||
outs: int
|
||||
strikeouts: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core formula functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_batter_value(stats) -> float:
|
||||
"""PA + (TB x 2) where TB = 1B + 2x2B + 3x3B + 4xHR."""
|
||||
singles = stats.hits - stats.doubles - stats.triples - stats.hr
|
||||
tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr
|
||||
return float(stats.pa + tb * 2)
|
||||
|
||||
|
||||
def _pitcher_value(stats) -> float:
|
||||
return stats.outs / 3 + stats.strikeouts
|
||||
|
||||
|
||||
def compute_sp_value(stats) -> float:
|
||||
"""IP + K where IP = outs / 3."""
|
||||
return _pitcher_value(stats)
|
||||
|
||||
|
||||
def compute_rp_value(stats) -> float:
|
||||
"""IP + K (same formula as SP; thresholds differ)."""
|
||||
return _pitcher_value(stats)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch and tier helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FORMULA_DISPATCH = {
|
||||
"batter": compute_batter_value,
|
||||
"sp": compute_sp_value,
|
||||
"rp": compute_rp_value,
|
||||
}
|
||||
|
||||
|
||||
def compute_value_for_track(card_type: str, stats) -> float:
|
||||
"""Dispatch to the correct formula function by card_type.
|
||||
|
||||
Args:
|
||||
card_type: One of 'batter', 'sp', 'rp'.
|
||||
stats: Object with the attributes required by the formula.
|
||||
|
||||
Raises:
|
||||
ValueError: If card_type is not recognised.
|
||||
"""
|
||||
fn = _FORMULA_DISPATCH.get(card_type)
|
||||
if fn is None:
|
||||
raise ValueError(f"Unknown card_type: {card_type!r}")
|
||||
return fn(stats)
|
||||
|
||||
|
||||
def tier_from_value(value: float, track) -> int:
|
||||
"""Return the evolution tier (0-4) for a computed value against a track.
|
||||
|
||||
Tier boundaries are inclusive on the lower end:
|
||||
T0: value < t1
|
||||
T1: t1 <= value < t2
|
||||
T2: t2 <= value < t3
|
||||
T3: t3 <= value < t4
|
||||
T4: value >= t4
|
||||
|
||||
Args:
|
||||
value: Computed formula value.
|
||||
track: Object (or dict-like) with t1_threshold..t4_threshold attributes/keys.
|
||||
"""
|
||||
# Support both attribute-style (Peewee model) and dict (seed fixture)
|
||||
if isinstance(track, dict):
|
||||
t1, t2, t3, t4 = (
|
||||
track["t1_threshold"],
|
||||
track["t2_threshold"],
|
||||
track["t3_threshold"],
|
||||
track["t4_threshold"],
|
||||
)
|
||||
else:
|
||||
t1, t2, t3, t4 = (
|
||||
track.t1_threshold,
|
||||
track.t2_threshold,
|
||||
track.t3_threshold,
|
||||
track.t4_threshold,
|
||||
)
|
||||
|
||||
if value >= t4:
|
||||
return 4
|
||||
if value >= t3:
|
||||
return 3
|
||||
if value >= t2:
|
||||
return 2
|
||||
if value >= t1:
|
||||
return 1
|
||||
return 0
|
||||
452
app/services/season_stats.py
Normal file
452
app/services/season_stats.py
Normal file
@ -0,0 +1,452 @@
|
||||
"""
|
||||
season_stats.py — Full-recalculation BattingSeasonStats and PitchingSeasonStats update logic.
|
||||
|
||||
Called once per completed StratGame to recompute the full season batting and
|
||||
pitching statistics for every player who appeared in that game, then write
|
||||
those totals to the batting_season_stats and pitching_season_stats tables.
|
||||
|
||||
Unlike the previous incremental (delta) approach, each call recomputes totals
|
||||
from scratch by aggregating all StratPlay rows for the player+team+season
|
||||
triple. This eliminates double-counting on re-delivery and makes every row a
|
||||
faithful snapshot of the full season to date.
|
||||
|
||||
Idempotency: re-delivery of a game is detected via the ProcessedGame ledger
|
||||
table, keyed on game_id.
|
||||
- First call: records the ledger entry and proceeds with recalculation.
|
||||
- Subsequent calls without force=True: return early with "skipped": True.
|
||||
- force=True: skips the early-return check and recalculates anyway (useful
|
||||
for correcting data after retroactive stat adjustments).
|
||||
|
||||
Upsert strategy: get_or_create + field assignment + save(). Because we are
|
||||
writing the full recomputed total rather than adding a delta, there is no
|
||||
risk of concurrent-write skew between games. A single unified path works for
|
||||
both SQLite and PostgreSQL.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from peewee import Case, fn
|
||||
|
||||
from app.db_engine import (
|
||||
db,
|
||||
BattingSeasonStats,
|
||||
Decision,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_player_pairs(game_id: int) -> tuple[set, set]:
|
||||
"""
|
||||
Return the sets of (player_id, team_id) pairs that appeared in the game.
|
||||
|
||||
Queries StratPlay for all rows belonging to game_id and extracts:
|
||||
- batting_pairs: set of (batter_id, batter_team_id), excluding rows where
|
||||
batter_id is None (e.g. automatic outs, walk-off plays without a PA).
|
||||
- pitching_pairs: set of (pitcher_id, pitcher_team_id) from all plays
|
||||
(pitcher is always present), plus any pitchers from the Decision table
|
||||
who may not have StratPlay rows (rare edge case).
|
||||
|
||||
Args:
|
||||
game_id: Primary key of the StratGame to query.
|
||||
|
||||
Returns:
|
||||
Tuple of (batting_pairs, pitching_pairs) where each element is a set
|
||||
of (int, int) tuples.
|
||||
"""
|
||||
plays = (
|
||||
StratPlay.select(
|
||||
StratPlay.batter,
|
||||
StratPlay.batter_team,
|
||||
StratPlay.pitcher,
|
||||
StratPlay.pitcher_team,
|
||||
)
|
||||
.where(StratPlay.game == game_id)
|
||||
.tuples()
|
||||
)
|
||||
|
||||
batting_pairs: set[tuple[int, int]] = set()
|
||||
pitching_pairs: set[tuple[int, int]] = set()
|
||||
|
||||
for batter_id, batter_team_id, pitcher_id, pitcher_team_id in plays:
|
||||
if batter_id is not None:
|
||||
batting_pairs.add((batter_id, batter_team_id))
|
||||
pitching_pairs.add((pitcher_id, pitcher_team_id))
|
||||
|
||||
# Include pitchers who have a Decision but no StratPlay rows for this game
|
||||
# (rare edge case, e.g. a pitcher credited with a decision without recording
|
||||
# any plays — the old code handled this explicitly in _apply_decisions).
|
||||
decision_pitchers = (
|
||||
Decision.select(Decision.pitcher, Decision.pitcher_team)
|
||||
.where(Decision.game == game_id)
|
||||
.tuples()
|
||||
)
|
||||
for pitcher_id, pitcher_team_id in decision_pitchers:
|
||||
pitching_pairs.add((pitcher_id, pitcher_team_id))
|
||||
|
||||
return batting_pairs, pitching_pairs
|
||||
|
||||
|
||||
def _recalc_batting(player_id: int, team_id: int, season: int) -> dict:
|
||||
"""
|
||||
Recompute full-season batting totals for a player+team+season triple.
|
||||
|
||||
Aggregates every StratPlay row where batter == player_id and
|
||||
batter_team == team_id across all games in the given season.
|
||||
|
||||
games counts only games where the player had at least one official PA
|
||||
(pa > 0). The COUNT(DISTINCT ...) with a CASE expression achieves this:
|
||||
NULL values from the CASE are ignored by COUNT, so only game IDs where
|
||||
pa > 0 contribute.
|
||||
|
||||
Args:
|
||||
player_id: FK to the player record.
|
||||
team_id: FK to the team record.
|
||||
season: Integer season year.
|
||||
|
||||
Returns:
|
||||
Dict with keys matching BattingSeasonStats columns; all values are
|
||||
native Python ints (defaulting to 0 if no rows matched).
|
||||
"""
|
||||
row = (
|
||||
StratPlay.select(
|
||||
fn.COUNT(
|
||||
Case(None, [(StratPlay.pa > 0, StratPlay.game)], None).distinct()
|
||||
).alias("games"),
|
||||
fn.SUM(StratPlay.pa).alias("pa"),
|
||||
fn.SUM(StratPlay.ab).alias("ab"),
|
||||
fn.SUM(StratPlay.hit).alias("hits"),
|
||||
fn.SUM(StratPlay.double).alias("doubles"),
|
||||
fn.SUM(StratPlay.triple).alias("triples"),
|
||||
fn.SUM(StratPlay.homerun).alias("hr"),
|
||||
fn.SUM(StratPlay.rbi).alias("rbi"),
|
||||
fn.SUM(StratPlay.run).alias("runs"),
|
||||
fn.SUM(StratPlay.bb).alias("bb"),
|
||||
fn.SUM(StratPlay.so).alias("strikeouts"),
|
||||
fn.SUM(StratPlay.hbp).alias("hbp"),
|
||||
fn.SUM(StratPlay.sac).alias("sac"),
|
||||
fn.SUM(StratPlay.ibb).alias("ibb"),
|
||||
fn.SUM(StratPlay.gidp).alias("gidp"),
|
||||
fn.SUM(StratPlay.sb).alias("sb"),
|
||||
fn.SUM(StratPlay.cs).alias("cs"),
|
||||
)
|
||||
.join(StratGame, on=(StratPlay.game == StratGame.id))
|
||||
.where(
|
||||
StratPlay.batter == player_id,
|
||||
StratPlay.batter_team == team_id,
|
||||
StratGame.season == season,
|
||||
)
|
||||
.dicts()
|
||||
.first()
|
||||
)
|
||||
|
||||
if row is None:
|
||||
row = {}
|
||||
|
||||
return {
|
||||
"games": row.get("games") or 0,
|
||||
"pa": row.get("pa") or 0,
|
||||
"ab": row.get("ab") or 0,
|
||||
"hits": row.get("hits") or 0,
|
||||
"doubles": row.get("doubles") or 0,
|
||||
"triples": row.get("triples") or 0,
|
||||
"hr": row.get("hr") or 0,
|
||||
"rbi": row.get("rbi") or 0,
|
||||
"runs": row.get("runs") or 0,
|
||||
"bb": row.get("bb") or 0,
|
||||
"strikeouts": row.get("strikeouts") or 0,
|
||||
"hbp": row.get("hbp") or 0,
|
||||
"sac": row.get("sac") or 0,
|
||||
"ibb": row.get("ibb") or 0,
|
||||
"gidp": row.get("gidp") or 0,
|
||||
"sb": row.get("sb") or 0,
|
||||
"cs": row.get("cs") or 0,
|
||||
}
|
||||
|
||||
|
||||
def _recalc_pitching(player_id: int, team_id: int, season: int) -> dict:
|
||||
"""
|
||||
Recompute full-season pitching totals for a player+team+season triple.
|
||||
|
||||
Aggregates every StratPlay row where pitcher == player_id and
|
||||
pitcher_team == team_id across all games in the given season. games counts
|
||||
all distinct games in which the pitcher appeared (any play qualifies).
|
||||
|
||||
Stats derived from StratPlay (from the batter-perspective columns):
|
||||
- outs = SUM(outs)
|
||||
- strikeouts = SUM(so) — batter SO = pitcher K
|
||||
- hits_allowed = SUM(hit)
|
||||
- bb = SUM(bb) — walks allowed
|
||||
- hbp = SUM(hbp)
|
||||
- hr_allowed = SUM(homerun)
|
||||
- wild_pitches = SUM(wild_pitch)
|
||||
- balks = SUM(balk)
|
||||
|
||||
Fields not available from StratPlay (runs_allowed, earned_runs) default
|
||||
to 0. Decision-level fields (wins, losses, etc.) are populated separately
|
||||
by _recalc_decisions() and merged in the caller.
|
||||
|
||||
Args:
|
||||
player_id: FK to the player record.
|
||||
team_id: FK to the team record.
|
||||
season: Integer season year.
|
||||
|
||||
Returns:
|
||||
Dict with keys matching PitchingSeasonStats columns (excluding
|
||||
decision fields, which are filled by _recalc_decisions).
|
||||
"""
|
||||
row = (
|
||||
StratPlay.select(
|
||||
fn.COUNT(StratPlay.game.distinct()).alias("games"),
|
||||
fn.SUM(StratPlay.outs).alias("outs"),
|
||||
fn.SUM(StratPlay.so).alias("strikeouts"),
|
||||
fn.SUM(StratPlay.hit).alias("hits_allowed"),
|
||||
fn.SUM(StratPlay.bb).alias("bb"),
|
||||
fn.SUM(StratPlay.hbp).alias("hbp"),
|
||||
fn.SUM(StratPlay.homerun).alias("hr_allowed"),
|
||||
fn.SUM(StratPlay.wild_pitch).alias("wild_pitches"),
|
||||
fn.SUM(StratPlay.balk).alias("balks"),
|
||||
)
|
||||
.join(StratGame, on=(StratPlay.game == StratGame.id))
|
||||
.where(
|
||||
StratPlay.pitcher == player_id,
|
||||
StratPlay.pitcher_team == team_id,
|
||||
StratGame.season == season,
|
||||
)
|
||||
.dicts()
|
||||
.first()
|
||||
)
|
||||
|
||||
if row is None:
|
||||
row = {}
|
||||
|
||||
return {
|
||||
"games": row.get("games") or 0,
|
||||
"outs": row.get("outs") or 0,
|
||||
"strikeouts": row.get("strikeouts") or 0,
|
||||
"hits_allowed": row.get("hits_allowed") or 0,
|
||||
"bb": row.get("bb") or 0,
|
||||
"hbp": row.get("hbp") or 0,
|
||||
"hr_allowed": row.get("hr_allowed") or 0,
|
||||
"wild_pitches": row.get("wild_pitches") or 0,
|
||||
"balks": row.get("balks") or 0,
|
||||
# Not available from play-by-play data
|
||||
"runs_allowed": 0,
|
||||
"earned_runs": 0,
|
||||
}
|
||||
|
||||
|
||||
def _recalc_decisions(player_id: int, team_id: int, season: int) -> dict:
|
||||
"""
|
||||
Recompute full-season decision totals for a pitcher+team+season triple.
|
||||
|
||||
Aggregates all Decision rows for the pitcher across the season. Decision
|
||||
rows are keyed by (pitcher, pitcher_team, season) independently of the
|
||||
StratPlay table, so this query is separate from _recalc_pitching().
|
||||
|
||||
Decision.is_start is a BooleanField; CAST to INTEGER before summing to
|
||||
ensure correct arithmetic across SQLite (True/False) and PostgreSQL
|
||||
(boolean).
|
||||
|
||||
Args:
|
||||
player_id: FK to the player record (pitcher).
|
||||
team_id: FK to the team record.
|
||||
season: Integer season year.
|
||||
|
||||
Returns:
|
||||
Dict with keys: wins, losses, holds, saves, blown_saves,
|
||||
games_started. All values are native Python ints.
|
||||
"""
|
||||
row = (
|
||||
Decision.select(
|
||||
fn.SUM(Decision.win).alias("wins"),
|
||||
fn.SUM(Decision.loss).alias("losses"),
|
||||
fn.SUM(Decision.hold).alias("holds"),
|
||||
fn.SUM(Decision.is_save).alias("saves"),
|
||||
fn.SUM(Decision.b_save).alias("blown_saves"),
|
||||
fn.SUM(Decision.is_start.cast("INTEGER")).alias("games_started"),
|
||||
)
|
||||
.where(
|
||||
Decision.pitcher == player_id,
|
||||
Decision.pitcher_team == team_id,
|
||||
Decision.season == season,
|
||||
)
|
||||
.dicts()
|
||||
.first()
|
||||
)
|
||||
|
||||
if row is None:
|
||||
row = {}
|
||||
|
||||
return {
|
||||
"wins": row.get("wins") or 0,
|
||||
"losses": row.get("losses") or 0,
|
||||
"holds": row.get("holds") or 0,
|
||||
"saves": row.get("saves") or 0,
|
||||
"blown_saves": row.get("blown_saves") or 0,
|
||||
"games_started": row.get("games_started") or 0,
|
||||
}
|
||||
|
||||
|
||||
def update_season_stats(game_id: int, force: bool = False) -> dict:
|
||||
"""
|
||||
Recompute full-season batting and pitching stats for every player in the game.
|
||||
|
||||
Unlike the previous incremental approach, this function recalculates each
|
||||
player's season totals from scratch by querying all StratPlay rows for
|
||||
the player+team+season triple. The resulting totals replace whatever was
|
||||
previously stored — no additive delta is applied.
|
||||
|
||||
Algorithm:
|
||||
1. Fetch StratGame to get the season.
|
||||
2. Check the ProcessedGame ledger:
|
||||
- If already processed and force=False, return early (skipped=True).
|
||||
- If already processed and force=True, continue (overwrite allowed).
|
||||
- If not yet processed, create the ledger entry.
|
||||
3. Determine (player_id, team_id) pairs via _get_player_pairs().
|
||||
4. For each batting pair: recompute season totals, then get_or_create
|
||||
BattingSeasonStats and overwrite all fields.
|
||||
5. For each pitching pair: recompute season play totals and decision
|
||||
totals, merge, then get_or_create PitchingSeasonStats and overwrite
|
||||
all fields.
|
||||
|
||||
Args:
|
||||
game_id: Primary key of the StratGame to process.
|
||||
force: If True, re-process even if the game was previously recorded
|
||||
in the ProcessedGame ledger. Useful for correcting stats after
|
||||
retroactive data adjustments.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
game_id — echoed back
|
||||
season — season integer from StratGame
|
||||
batters_updated — number of BattingSeasonStats rows written
|
||||
pitchers_updated — number of PitchingSeasonStats rows written
|
||||
skipped — True only when the game was already processed
|
||||
and force=False; absent otherwise.
|
||||
|
||||
Raises:
|
||||
StratGame.DoesNotExist: If no StratGame row matches game_id.
|
||||
"""
|
||||
logger.info("update_season_stats: starting for game_id=%d force=%s", game_id, force)
|
||||
|
||||
game = StratGame.get_by_id(game_id)
|
||||
season = game.season
|
||||
|
||||
with db.atomic():
|
||||
# Idempotency check via ProcessedGame ledger.
|
||||
_, created = ProcessedGame.get_or_create(game_id=game_id)
|
||||
|
||||
if not created and not force:
|
||||
logger.info(
|
||||
"update_season_stats: game_id=%d already processed, skipping",
|
||||
game_id,
|
||||
)
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"season": season,
|
||||
"batters_updated": 0,
|
||||
"pitchers_updated": 0,
|
||||
"skipped": True,
|
||||
}
|
||||
|
||||
if not created and force:
|
||||
logger.info(
|
||||
"update_season_stats: game_id=%d already processed, force=True — recalculating",
|
||||
game_id,
|
||||
)
|
||||
|
||||
batting_pairs, pitching_pairs = _get_player_pairs(game_id)
|
||||
logger.debug(
|
||||
"update_season_stats: game_id=%d found %d batting pairs, %d pitching pairs",
|
||||
game_id,
|
||||
len(batting_pairs),
|
||||
len(pitching_pairs),
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Recompute and overwrite batting season stats for each batter.
|
||||
batters_updated = 0
|
||||
for player_id, team_id in batting_pairs:
|
||||
stats = _recalc_batting(player_id, team_id, season)
|
||||
|
||||
obj, _ = BattingSeasonStats.get_or_create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
season=season,
|
||||
)
|
||||
obj.games = stats["games"]
|
||||
obj.pa = stats["pa"]
|
||||
obj.ab = stats["ab"]
|
||||
obj.hits = stats["hits"]
|
||||
obj.doubles = stats["doubles"]
|
||||
obj.triples = stats["triples"]
|
||||
obj.hr = stats["hr"]
|
||||
obj.rbi = stats["rbi"]
|
||||
obj.runs = stats["runs"]
|
||||
obj.bb = stats["bb"]
|
||||
obj.strikeouts = stats["strikeouts"]
|
||||
obj.hbp = stats["hbp"]
|
||||
obj.sac = stats["sac"]
|
||||
obj.ibb = stats["ibb"]
|
||||
obj.gidp = stats["gidp"]
|
||||
obj.sb = stats["sb"]
|
||||
obj.cs = stats["cs"]
|
||||
obj.last_game_id = game_id
|
||||
obj.last_updated_at = now
|
||||
obj.save()
|
||||
batters_updated += 1
|
||||
|
||||
# Recompute and overwrite pitching season stats for each pitcher.
|
||||
pitchers_updated = 0
|
||||
for player_id, team_id in pitching_pairs:
|
||||
play_stats = _recalc_pitching(player_id, team_id, season)
|
||||
decision_stats = _recalc_decisions(player_id, team_id, season)
|
||||
|
||||
obj, _ = PitchingSeasonStats.get_or_create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
season=season,
|
||||
)
|
||||
obj.games = play_stats["games"]
|
||||
obj.games_started = decision_stats["games_started"]
|
||||
obj.outs = play_stats["outs"]
|
||||
obj.strikeouts = play_stats["strikeouts"]
|
||||
obj.bb = play_stats["bb"]
|
||||
obj.hits_allowed = play_stats["hits_allowed"]
|
||||
obj.runs_allowed = play_stats["runs_allowed"]
|
||||
obj.earned_runs = play_stats["earned_runs"]
|
||||
obj.hr_allowed = play_stats["hr_allowed"]
|
||||
obj.hbp = play_stats["hbp"]
|
||||
obj.wild_pitches = play_stats["wild_pitches"]
|
||||
obj.balks = play_stats["balks"]
|
||||
obj.wins = decision_stats["wins"]
|
||||
obj.losses = decision_stats["losses"]
|
||||
obj.holds = decision_stats["holds"]
|
||||
obj.saves = decision_stats["saves"]
|
||||
obj.blown_saves = decision_stats["blown_saves"]
|
||||
obj.last_game_id = game_id
|
||||
obj.last_updated_at = now
|
||||
obj.save()
|
||||
pitchers_updated += 1
|
||||
|
||||
logger.info(
|
||||
"update_season_stats: game_id=%d complete — "
|
||||
"batters_updated=%d pitchers_updated=%d",
|
||||
game_id,
|
||||
batters_updated,
|
||||
pitchers_updated,
|
||||
)
|
||||
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"season": season,
|
||||
"batters_updated": batters_updated,
|
||||
"pitchers_updated": pitchers_updated,
|
||||
}
|
||||
241
migrations/2026-03-17_add_evolution_tables.sql
Normal file
241
migrations/2026-03-17_add_evolution_tables.sql
Normal file
@ -0,0 +1,241 @@
|
||||
-- Migration: Add card evolution tables and column extensions
|
||||
-- Date: 2026-03-17
|
||||
-- Issue: WP-04
|
||||
-- Purpose: Support the Card Evolution system — creates batting_season_stats
|
||||
-- and pitching_season_stats for per-player stat accumulation, plus
|
||||
-- evolution tracks with tier thresholds, per-card evolution state,
|
||||
-- tier-based stat boosts, and cosmetic unlocks. Also extends the
|
||||
-- card, battingcard, and pitchingcard tables with variant and
|
||||
-- image_url columns required by the evolution display layer.
|
||||
--
|
||||
-- Run on dev first, verify with:
|
||||
-- SELECT count(*) FROM batting_season_stats;
|
||||
-- SELECT count(*) FROM pitching_season_stats;
|
||||
-- SELECT count(*) FROM evolution_track;
|
||||
-- SELECT count(*) FROM evolution_card_state;
|
||||
-- SELECT count(*) FROM evolution_tier_boost;
|
||||
-- SELECT count(*) FROM evolution_cosmetic;
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_name IN ('card', 'battingcard', 'pitchingcard')
|
||||
-- AND column_name IN ('variant', 'image_url')
|
||||
-- ORDER BY table_name, column_name;
|
||||
--
|
||||
-- Rollback: See DROP/ALTER statements at bottom of file
|
||||
|
||||
-- ============================================
|
||||
-- FORWARD MIGRATION
|
||||
-- ============================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 1: batting_season_stats
|
||||
-- Accumulates per-player per-team per-season
|
||||
-- batting totals for evolution formula evaluation
|
||||
-- and leaderboard queries.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS batting_season_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||
season INTEGER NOT NULL,
|
||||
games INTEGER NOT NULL DEFAULT 0,
|
||||
pa INTEGER NOT NULL DEFAULT 0,
|
||||
ab INTEGER NOT NULL DEFAULT 0,
|
||||
hits INTEGER NOT NULL DEFAULT 0,
|
||||
doubles INTEGER NOT NULL DEFAULT 0,
|
||||
triples INTEGER NOT NULL DEFAULT 0,
|
||||
hr INTEGER NOT NULL DEFAULT 0,
|
||||
rbi INTEGER NOT NULL DEFAULT 0,
|
||||
runs INTEGER NOT NULL DEFAULT 0,
|
||||
bb INTEGER NOT NULL DEFAULT 0,
|
||||
strikeouts INTEGER NOT NULL DEFAULT 0,
|
||||
hbp INTEGER NOT NULL DEFAULT 0,
|
||||
sac INTEGER NOT NULL DEFAULT 0,
|
||||
ibb INTEGER NOT NULL DEFAULT 0,
|
||||
gidp INTEGER NOT NULL DEFAULT 0,
|
||||
sb INTEGER NOT NULL DEFAULT 0,
|
||||
cs INTEGER NOT NULL DEFAULT 0,
|
||||
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||
last_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per player per team per season
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS batting_season_stats_player_team_season_uniq
|
||||
ON batting_season_stats (player_id, team_id, season);
|
||||
|
||||
-- Fast lookup by team + season (e.g. leaderboard queries)
|
||||
CREATE INDEX IF NOT EXISTS batting_season_stats_team_season_idx
|
||||
ON batting_season_stats (team_id, season);
|
||||
|
||||
-- Fast lookup by player across seasons
|
||||
CREATE INDEX IF NOT EXISTS batting_season_stats_player_season_idx
|
||||
ON batting_season_stats (player_id, season);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 2: pitching_season_stats
|
||||
-- Accumulates per-player per-team per-season
|
||||
-- pitching totals for evolution formula evaluation
|
||||
-- and leaderboard queries.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pitching_season_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||
season INTEGER NOT NULL,
|
||||
games INTEGER NOT NULL DEFAULT 0,
|
||||
games_started INTEGER NOT NULL DEFAULT 0,
|
||||
outs INTEGER NOT NULL DEFAULT 0,
|
||||
strikeouts INTEGER NOT NULL DEFAULT 0,
|
||||
bb INTEGER NOT NULL DEFAULT 0,
|
||||
hits_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
runs_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
earned_runs INTEGER NOT NULL DEFAULT 0,
|
||||
hr_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
hbp INTEGER NOT NULL DEFAULT 0,
|
||||
wild_pitches INTEGER NOT NULL DEFAULT 0,
|
||||
balks INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
holds INTEGER NOT NULL DEFAULT 0,
|
||||
saves INTEGER NOT NULL DEFAULT 0,
|
||||
blown_saves INTEGER NOT NULL DEFAULT 0,
|
||||
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||
last_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per player per team per season
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS pitching_season_stats_player_team_season_uniq
|
||||
ON pitching_season_stats (player_id, team_id, season);
|
||||
|
||||
-- Fast lookup by team + season (e.g. leaderboard queries)
|
||||
CREATE INDEX IF NOT EXISTS pitching_season_stats_team_season_idx
|
||||
ON pitching_season_stats (team_id, season);
|
||||
|
||||
-- Fast lookup by player across seasons
|
||||
CREATE INDEX IF NOT EXISTS pitching_season_stats_player_season_idx
|
||||
ON pitching_season_stats (player_id, season);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 3: evolution_track
|
||||
-- Defines the available evolution tracks
|
||||
-- (e.g. "HR Mastery", "Ace SP"), their
|
||||
-- metric formula, and the four tier thresholds.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_track (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
card_type VARCHAR(50) NOT NULL, -- 'batter', 'sp', or 'rp'
|
||||
formula VARCHAR(255) NOT NULL, -- e.g. 'hr', 'k_per_9', 'ops'
|
||||
t1_threshold INTEGER NOT NULL,
|
||||
t2_threshold INTEGER NOT NULL,
|
||||
t3_threshold INTEGER NOT NULL,
|
||||
t4_threshold INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 4: evolution_card_state
|
||||
-- Records each card's current evolution tier,
|
||||
-- running metric value, and the track it
|
||||
-- belongs to. One state row per card (player
|
||||
-- + team combination uniquely identifies a
|
||||
-- card in a given season).
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_card_state (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||
track_id INTEGER NOT NULL REFERENCES evolution_track(id) ON DELETE CASCADE,
|
||||
current_tier INTEGER NOT NULL DEFAULT 0,
|
||||
current_value DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
fully_evolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_evaluated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- One evolution state per card (player + team)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS evolution_card_state_player_team_uniq
|
||||
ON evolution_card_state (player_id, team_id);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 5: evolution_tier_boost
|
||||
-- Defines the stat boosts unlocked at each
|
||||
-- tier within a track. A single tier may
|
||||
-- grant multiple boosts (e.g. +1 HR and
|
||||
-- +1 power rating).
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_tier_boost (
|
||||
id SERIAL PRIMARY KEY,
|
||||
track_id INTEGER NOT NULL REFERENCES evolution_track(id) ON DELETE CASCADE,
|
||||
tier INTEGER NOT NULL, -- 1-4
|
||||
boost_type VARCHAR(50) NOT NULL, -- e.g. 'rating_bump', 'display_only'
|
||||
boost_target VARCHAR(50) NOT NULL, -- e.g. 'hr_rating', 'contact_rating'
|
||||
boost_value DOUBLE PRECISION NOT NULL DEFAULT 0.0
|
||||
);
|
||||
|
||||
-- Prevent duplicate boost definitions for the same track/tier/type/target
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS evolution_tier_boost_track_tier_type_target_uniq
|
||||
ON evolution_tier_boost (track_id, tier, boost_type, boost_target);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 6: evolution_cosmetic
|
||||
-- Catalogue of unlockable visual treatments
|
||||
-- (borders, foils, badges, etc.) tied to
|
||||
-- minimum tier requirements.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_cosmetic (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
tier_required INTEGER NOT NULL DEFAULT 0,
|
||||
cosmetic_type VARCHAR(50) NOT NULL, -- e.g. 'border', 'foil', 'badge'
|
||||
css_class VARCHAR(255),
|
||||
asset_url VARCHAR(500)
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Column extensions for existing tables
|
||||
-- --------------------------------------------
|
||||
|
||||
-- Track which visual variant a card is displaying
|
||||
-- (NULL = base card, 1+ = evolved variants)
|
||||
ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL;
|
||||
|
||||
-- Store pre-rendered or externally-hosted card image URLs
|
||||
ALTER TABLE battingcard ADD COLUMN IF NOT EXISTS image_url VARCHAR(500);
|
||||
ALTER TABLE pitchingcard ADD COLUMN IF NOT EXISTS image_url VARCHAR(500);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================
|
||||
-- VERIFICATION QUERIES
|
||||
-- ============================================
|
||||
-- \d batting_season_stats
|
||||
-- \d pitching_season_stats
|
||||
-- \d evolution_track
|
||||
-- \d evolution_card_state
|
||||
-- \d evolution_tier_boost
|
||||
-- \d evolution_cosmetic
|
||||
-- SELECT indexname FROM pg_indexes
|
||||
-- WHERE tablename IN (
|
||||
-- 'batting_season_stats',
|
||||
-- 'pitching_season_stats',
|
||||
-- 'evolution_card_state',
|
||||
-- 'evolution_tier_boost'
|
||||
-- )
|
||||
-- ORDER BY tablename, indexname;
|
||||
-- SELECT column_name, data_type FROM information_schema.columns
|
||||
-- WHERE table_name IN ('card', 'battingcard', 'pitchingcard')
|
||||
-- AND column_name IN ('variant', 'image_url')
|
||||
-- ORDER BY table_name, column_name;
|
||||
|
||||
-- ============================================
|
||||
-- ROLLBACK (if needed)
|
||||
-- ============================================
|
||||
-- ALTER TABLE pitchingcard DROP COLUMN IF EXISTS image_url;
|
||||
-- ALTER TABLE battingcard DROP COLUMN IF EXISTS image_url;
|
||||
-- ALTER TABLE card DROP COLUMN IF EXISTS variant;
|
||||
-- DROP TABLE IF EXISTS evolution_cosmetic CASCADE;
|
||||
-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE;
|
||||
-- DROP TABLE IF EXISTS evolution_card_state CASCADE;
|
||||
-- DROP TABLE IF EXISTS evolution_track CASCADE;
|
||||
-- DROP TABLE IF EXISTS pitching_season_stats CASCADE;
|
||||
-- DROP TABLE IF EXISTS batting_season_stats CASCADE;
|
||||
26
migrations/2026-03-18_add_processed_game.sql
Normal file
26
migrations/2026-03-18_add_processed_game.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- Migration: Add processed_game ledger for full update_season_stats() idempotency
|
||||
-- Date: 2026-03-18
|
||||
-- Issue: #105
|
||||
-- Purpose: Replace the last_game FK check in update_season_stats() with an
|
||||
-- atomic INSERT into processed_game. This prevents out-of-order
|
||||
-- re-delivery (game G re-delivered after G+1 was already processed)
|
||||
-- from bypassing the guard and double-counting stats.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_game (
|
||||
game_id INTEGER PRIMARY KEY REFERENCES stratgame(id) ON DELETE CASCADE,
|
||||
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================
|
||||
-- VERIFICATION QUERIES
|
||||
-- ============================================
|
||||
-- \d processed_game
|
||||
|
||||
-- ============================================
|
||||
-- ROLLBACK (if needed)
|
||||
-- ============================================
|
||||
-- DROP TABLE IF EXISTS processed_game;
|
||||
5
pyproject.toml
Normal file
5
pyproject.toml
Normal file
@ -0,0 +1,5 @@
|
||||
[tool.ruff]
|
||||
[tool.ruff.lint]
|
||||
# db_engine.py uses `from peewee import *` throughout — a pre-existing
|
||||
# codebase pattern. Suppress wildcard-import warnings for that file only.
|
||||
per-file-ignores = { "app/db_engine.py" = ["F401", "F403", "F405"], "app/main.py" = ["E402", "F541"] }
|
||||
@ -1,15 +1,14 @@
|
||||
pydantic==1.*
|
||||
fastapi
|
||||
uvicorn
|
||||
peewee
|
||||
psycopg2-binary # PostgreSQL adapter for Python
|
||||
python-multipart
|
||||
numpy<2
|
||||
pandas
|
||||
pygsheets
|
||||
pybaseball
|
||||
python-multipart
|
||||
requests
|
||||
html2image
|
||||
jinja2
|
||||
playwright
|
||||
pydantic==1.10.21
|
||||
fastapi==0.111.1
|
||||
uvicorn==0.30.6
|
||||
peewee==3.17.9
|
||||
psycopg2-binary==2.9.9
|
||||
python-multipart==0.0.9
|
||||
numpy==1.26.4
|
||||
pandas==2.2.3
|
||||
pygsheets==2.0.6
|
||||
pybaseball==2.2.7
|
||||
requests==2.32.3
|
||||
html2image==2.0.6
|
||||
jinja2==3.1.4
|
||||
playwright==1.45.1
|
||||
|
||||
3
ruff.toml
Normal file
3
ruff.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[lint]
|
||||
# db_engine.py uses `from peewee import *` intentionally — suppress star-import warnings
|
||||
ignore = ["F403", "F405"]
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
212
tests/conftest.py
Normal file
212
tests/conftest.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""
|
||||
Shared test fixtures for the Paper Dynasty database test suite.
|
||||
|
||||
Uses in-memory SQLite with foreign_keys pragma enabled. Each test
|
||||
gets a fresh set of tables via the setup_test_db fixture (autouse).
|
||||
|
||||
All models are bound to the in-memory database before table creation
|
||||
so that no connection to the real storage/pd_master.db occurs during
|
||||
tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import psycopg2
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION
|
||||
# flag is True. This prevents db_engine.py from calling create_tables()
|
||||
# against the real storage/pd_master.db during import — those calls would
|
||||
# fail if indexes already exist and would also contaminate the dev database.
|
||||
# The PooledPostgresqlDatabase object is created but never actually connects
|
||||
# because our fixture rebinds all models to an in-memory SQLite db before
|
||||
# any query is executed.
|
||||
os.environ["DATABASE_TYPE"] = "postgresql"
|
||||
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
||||
# without raising a configuration error (it will not actually be used).
|
||||
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
||||
|
||||
from app.db_engine import (
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
PackType,
|
||||
Pack,
|
||||
Card,
|
||||
Roster,
|
||||
RosterSlot,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
EvolutionCosmetic,
|
||||
ScoutOpportunity,
|
||||
ScoutClaim,
|
||||
)
|
||||
|
||||
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
||||
|
||||
# All models in dependency order (parents before children) so that
|
||||
# create_tables and drop_tables work without FK violations.
|
||||
_TEST_MODELS = [
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
PackType,
|
||||
Pack,
|
||||
Card,
|
||||
Roster,
|
||||
RosterSlot,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
ScoutOpportunity,
|
||||
ScoutClaim,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
EvolutionCosmetic,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_db():
|
||||
"""Bind all models to in-memory SQLite and create tables.
|
||||
|
||||
The fixture is autouse so every test automatically gets a fresh,
|
||||
isolated database schema without needing to request it explicitly.
|
||||
Tables are dropped in reverse dependency order after each test to
|
||||
keep the teardown clean and to catch any accidental FK reference
|
||||
direction bugs early.
|
||||
"""
|
||||
_test_db.bind(_TEST_MODELS)
|
||||
_test_db.connect()
|
||||
_test_db.create_tables(_TEST_MODELS)
|
||||
yield _test_db
|
||||
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
||||
_test_db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal shared fixtures — create just enough data for FK dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rarity():
|
||||
"""A single Common rarity row used as FK seed for Player rows."""
|
||||
return Rarity.create(value=1, name="Common", color="#ffffff")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(rarity):
|
||||
"""A minimal Player row with all required (non-nullable) columns filled.
|
||||
|
||||
Player.p_name is the real column name (not 'name'). All FK and
|
||||
non-nullable varchar fields are provided so SQLite's NOT NULL
|
||||
constraints are satisfied even with foreign_keys=ON.
|
||||
"""
|
||||
cardset = Cardset.create(
|
||||
name="Test Set",
|
||||
description="Test cardset",
|
||||
total_cards=100,
|
||||
)
|
||||
return Player.create(
|
||||
p_name="Test Player",
|
||||
rarity=rarity,
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
pos_1="1B",
|
||||
image="https://example.com/image.png",
|
||||
mlbclub="TST",
|
||||
franchise="TST",
|
||||
description="A test player",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team():
|
||||
"""A minimal Team row.
|
||||
|
||||
Team uses abbrev/lname/sname/gmid/gmname/gsheet/wallet/team_value/
|
||||
collection_value — not the 'name'/'user_id' shorthand described in
|
||||
the spec, which referred to the real underlying columns by
|
||||
simplified names.
|
||||
"""
|
||||
return Team.create(
|
||||
abbrev="TST",
|
||||
sname="Test",
|
||||
lname="Test Team",
|
||||
gmid=100000001,
|
||||
gmname="testuser",
|
||||
gsheet="https://docs.google.com/spreadsheets/test",
|
||||
wallet=500,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=11,
|
||||
is_ai=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def track():
|
||||
"""A minimal EvolutionTrack for batter cards."""
|
||||
return EvolutionTrack.create(
|
||||
name="Batter Track",
|
||||
card_type="batter",
|
||||
formula="pa + tb * 2",
|
||||
t1_threshold=37,
|
||||
t2_threshold=149,
|
||||
t3_threshold=448,
|
||||
t4_threshold=896,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL integration fixture (used by test_evolution_*_api.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_conn():
|
||||
"""Open a psycopg2 connection to the PostgreSQL instance for integration tests.
|
||||
|
||||
Reads connection parameters from the standard POSTGRES_* env vars that the
|
||||
CI workflow injects when a postgres service container is running. Skips the
|
||||
entire session (via pytest.skip) when POSTGRES_HOST is not set, keeping
|
||||
local runs clean.
|
||||
|
||||
The connection is shared for the whole session (scope="session") because
|
||||
the integration test modules use module-scoped fixtures that rely on it;
|
||||
creating a new connection per test would break those module-scoped fixtures.
|
||||
|
||||
Teardown: the connection is closed once all tests have finished.
|
||||
"""
|
||||
host = os.environ.get("POSTGRES_HOST")
|
||||
if not host:
|
||||
pytest.skip("POSTGRES_HOST not set — PostgreSQL integration tests skipped")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=int(os.environ.get("POSTGRES_PORT", "5432")),
|
||||
dbname=os.environ.get("POSTGRES_DB", "paper_dynasty"),
|
||||
user=os.environ.get("POSTGRES_USER", "postgres"),
|
||||
password=os.environ.get("POSTGRES_PASSWORD", ""),
|
||||
)
|
||||
conn.autocommit = False
|
||||
yield conn
|
||||
conn.close()
|
||||
361
tests/test_evolution_evaluator.py
Normal file
361
tests/test_evolution_evaluator.py
Normal file
@ -0,0 +1,361 @@
|
||||
"""Tests for the evolution evaluator service (WP-08).
|
||||
|
||||
Unit tests verify tier assignment, advancement, partial progress, idempotency,
|
||||
full evolution, and no-regression behaviour without touching any database,
|
||||
using stub Peewee models bound to an in-memory SQLite database.
|
||||
|
||||
The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported
|
||||
from db_engine/formula_engine; instead the tests supply minimal stubs and
|
||||
inject them via the _stats_model, _state_model, _compute_value_fn, and
|
||||
_tier_from_value_fn overrides on evaluate_card().
|
||||
|
||||
Stub track thresholds (batter):
|
||||
T1: 37 T2: 149 T3: 448 T4: 896
|
||||
|
||||
Useful reference values:
|
||||
value=30 → T0 (below T1=37)
|
||||
value=50 → T1 (37 <= 50 < 149)
|
||||
value=100 → T1 (stays T1; T2 threshold is 149)
|
||||
value=160 → T2 (149 <= 160 < 448)
|
||||
value=900 → T4 (>= 896) → fully_evolved
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from peewee import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
DateTimeField,
|
||||
FloatField,
|
||||
ForeignKeyField,
|
||||
IntegerField,
|
||||
Model,
|
||||
SqliteDatabase,
|
||||
)
|
||||
|
||||
from app.services.evolution_evaluator import evaluate_card
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub models — mirror WP-01/WP-04/WP-07 schema without importing db_engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_test_db = SqliteDatabase(":memory:")
|
||||
|
||||
|
||||
class TrackStub(Model):
|
||||
"""Minimal EvolutionTrack stub for evaluator tests."""
|
||||
|
||||
card_type = CharField(unique=True)
|
||||
t1_threshold = IntegerField()
|
||||
t2_threshold = IntegerField()
|
||||
t3_threshold = IntegerField()
|
||||
t4_threshold = IntegerField()
|
||||
|
||||
class Meta:
|
||||
database = _test_db
|
||||
table_name = "evolution_track"
|
||||
|
||||
|
||||
class CardStateStub(Model):
|
||||
"""Minimal EvolutionCardState stub for evaluator tests."""
|
||||
|
||||
player_id = IntegerField()
|
||||
team_id = IntegerField()
|
||||
track = ForeignKeyField(TrackStub)
|
||||
current_tier = IntegerField(default=0)
|
||||
current_value = FloatField(default=0.0)
|
||||
fully_evolved = BooleanField(default=False)
|
||||
last_evaluated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = _test_db
|
||||
table_name = "evolution_card_state"
|
||||
indexes = ((("player_id", "team_id"), True),)
|
||||
|
||||
|
||||
class StatsStub(Model):
|
||||
"""Minimal PlayerSeasonStats stub for evaluator tests."""
|
||||
|
||||
player_id = IntegerField()
|
||||
team_id = IntegerField()
|
||||
season = IntegerField()
|
||||
pa = IntegerField(default=0)
|
||||
hits = IntegerField(default=0)
|
||||
doubles = IntegerField(default=0)
|
||||
triples = IntegerField(default=0)
|
||||
hr = IntegerField(default=0)
|
||||
outs = IntegerField(default=0)
|
||||
strikeouts = IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
database = _test_db
|
||||
table_name = "player_season_stats"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formula stubs — avoid importing app.services.formula_engine before WP-09
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_value(card_type: str, stats) -> float:
|
||||
"""Stub compute_value_for_track: returns pa for batter, outs/3+k for pitchers."""
|
||||
if card_type == "batter":
|
||||
singles = stats.hits - stats.doubles - stats.triples - stats.hr
|
||||
tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr
|
||||
return float(stats.pa + tb * 2)
|
||||
return stats.outs / 3 + stats.strikeouts
|
||||
|
||||
|
||||
def _tier_from_value(value: float, track) -> int:
|
||||
"""Stub tier_from_value using TrackStub fields t1_threshold/t2_threshold/etc."""
|
||||
if isinstance(track, dict):
|
||||
t1, t2, t3, t4 = (
|
||||
track["t1_threshold"],
|
||||
track["t2_threshold"],
|
||||
track["t3_threshold"],
|
||||
track["t4_threshold"],
|
||||
)
|
||||
else:
|
||||
t1, t2, t3, t4 = (
|
||||
track.t1_threshold,
|
||||
track.t2_threshold,
|
||||
track.t3_threshold,
|
||||
track.t4_threshold,
|
||||
)
|
||||
if value >= t4:
|
||||
return 4
|
||||
if value >= t3:
|
||||
return 3
|
||||
if value >= t2:
|
||||
return 2
|
||||
if value >= t1:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _db():
|
||||
"""Create tables before each test and drop them afterwards."""
|
||||
_test_db.connect(reuse_if_open=True)
|
||||
_test_db.create_tables([TrackStub, CardStateStub, StatsStub])
|
||||
yield
|
||||
_test_db.drop_tables([StatsStub, CardStateStub, TrackStub])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def batter_track():
|
||||
return TrackStub.create(
|
||||
card_type="batter",
|
||||
t1_threshold=37,
|
||||
t2_threshold=149,
|
||||
t3_threshold=448,
|
||||
t4_threshold=896,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sp_track():
|
||||
return TrackStub.create(
|
||||
card_type="sp",
|
||||
t1_threshold=10,
|
||||
t2_threshold=40,
|
||||
t3_threshold=120,
|
||||
t4_threshold=240,
|
||||
)
|
||||
|
||||
|
||||
def _make_state(player_id, team_id, track, current_tier=0, current_value=0.0):
|
||||
return CardStateStub.create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
track=track,
|
||||
current_tier=current_tier,
|
||||
current_value=current_value,
|
||||
fully_evolved=False,
|
||||
last_evaluated_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_stats(player_id, team_id, season, **kwargs):
|
||||
return StatsStub.create(
|
||||
player_id=player_id, team_id=team_id, season=season, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _eval(player_id, team_id):
|
||||
return evaluate_card(
|
||||
player_id,
|
||||
team_id,
|
||||
_stats_model=StatsStub,
|
||||
_state_model=CardStateStub,
|
||||
_compute_value_fn=_compute_value,
|
||||
_tier_from_value_fn=_tier_from_value,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTierAssignment:
|
||||
"""Tier assigned from computed value against track thresholds."""
|
||||
|
||||
def test_value_below_t1_stays_t0(self, batter_track):
|
||||
"""value=30 is below T1 threshold (37) → tier stays 0."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=30, no extra hits → value = 30 + 0 = 30 < 37
|
||||
_make_stats(1, 1, 1, pa=30)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 0
|
||||
|
||||
def test_value_at_t1_threshold_assigns_tier_1(self, batter_track):
|
||||
"""value=50 → T1 (37 <= 50 < 149)."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=50, no hits → value = 50 + 0 = 50
|
||||
_make_stats(1, 1, 1, pa=50)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 1
|
||||
|
||||
def test_tier_advancement_to_t2(self, batter_track):
|
||||
"""value=160 → T2 (149 <= 160 < 448)."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=160, no hits → value = 160
|
||||
_make_stats(1, 1, 1, pa=160)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 2
|
||||
|
||||
def test_partial_progress_stays_t1(self, batter_track):
|
||||
"""value=100 with T2=149 → stays T1, does not advance to T2."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=100 → value = 100, T2 threshold = 149 → tier 1
|
||||
_make_stats(1, 1, 1, pa=100)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 1
|
||||
assert result["fully_evolved"] is False
|
||||
|
||||
def test_fully_evolved_at_t4(self, batter_track):
|
||||
"""value >= T4 (896) → tier=4 and fully_evolved=True."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=900 → value = 900 >= 896
|
||||
_make_stats(1, 1, 1, pa=900)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 4
|
||||
assert result["fully_evolved"] is True
|
||||
|
||||
|
||||
class TestNoRegression:
|
||||
"""current_tier never decreases."""
|
||||
|
||||
def test_tier_never_decreases(self, batter_track):
|
||||
"""If current_tier=2 and new value only warrants T1, tier stays 2."""
|
||||
# Seed state at tier 2
|
||||
_make_state(1, 1, batter_track, current_tier=2, current_value=160.0)
|
||||
# Sparse stats: value=50 → would be T1, but current is T2
|
||||
_make_stats(1, 1, 1, pa=50)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 2 # no regression
|
||||
|
||||
def test_tier_advances_when_value_improves(self, batter_track):
|
||||
"""If current_tier=1 and new value warrants T3, tier advances to 3."""
|
||||
_make_state(1, 1, batter_track, current_tier=1, current_value=50.0)
|
||||
# pa=500 → value = 500 >= 448 → T3
|
||||
_make_stats(1, 1, 1, pa=500)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 3
|
||||
|
||||
|
||||
class TestIdempotency:
|
||||
"""Calling evaluate_card twice with same stats returns the same result."""
|
||||
|
||||
def test_idempotent_same_result(self, batter_track):
|
||||
"""Two evaluations with identical stats produce the same tier and value."""
|
||||
_make_state(1, 1, batter_track)
|
||||
_make_stats(1, 1, 1, pa=160)
|
||||
result1 = _eval(1, 1)
|
||||
result2 = _eval(1, 1)
|
||||
assert result1["current_tier"] == result2["current_tier"]
|
||||
assert result1["current_value"] == result2["current_value"]
|
||||
assert result1["fully_evolved"] == result2["fully_evolved"]
|
||||
|
||||
def test_idempotent_at_fully_evolved(self, batter_track):
|
||||
"""Repeated evaluation at T4 remains fully_evolved=True."""
|
||||
_make_state(1, 1, batter_track)
|
||||
_make_stats(1, 1, 1, pa=900)
|
||||
_eval(1, 1)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 4
|
||||
assert result["fully_evolved"] is True
|
||||
|
||||
|
||||
class TestCareerTotals:
|
||||
"""Stats are summed across all seasons for the player/team pair."""
|
||||
|
||||
def test_multi_season_stats_summed(self, batter_track):
|
||||
"""Stats from two seasons are aggregated into a single career total."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# Season 1: pa=80, Season 2: pa=90 → total pa=170 → value=170 → T2
|
||||
_make_stats(1, 1, 1, pa=80)
|
||||
_make_stats(1, 1, 2, pa=90)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 2
|
||||
assert result["current_value"] == 170.0
|
||||
|
||||
def test_zero_stats_stays_t0(self, batter_track):
|
||||
"""No stats rows → all zeros → value=0 → tier=0."""
|
||||
_make_state(1, 1, batter_track)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 0
|
||||
assert result["current_value"] == 0.0
|
||||
|
||||
def test_other_team_stats_not_included(self, batter_track):
|
||||
"""Stats for the same player on a different team are not counted."""
|
||||
_make_state(1, 1, batter_track)
|
||||
_make_stats(1, 1, 1, pa=50)
|
||||
# Same player, different team — should not count
|
||||
_make_stats(1, 2, 1, pa=200)
|
||||
result = _eval(1, 1)
|
||||
# Only pa=50 counted → value=50 → T1
|
||||
assert result["current_tier"] == 1
|
||||
assert result["current_value"] == 50.0
|
||||
|
||||
|
||||
class TestMissingState:
|
||||
"""ValueError when no card state exists for (player_id, team_id)."""
|
||||
|
||||
def test_missing_state_raises(self, batter_track):
|
||||
"""evaluate_card raises ValueError when no state row exists."""
|
||||
# No card state created
|
||||
with pytest.raises(ValueError, match="No evolution_card_state"):
|
||||
_eval(99, 99)
|
||||
|
||||
|
||||
class TestReturnShape:
|
||||
"""Return dict has the expected keys and types."""
|
||||
|
||||
def test_return_keys(self, batter_track):
|
||||
"""Result dict contains all expected keys."""
|
||||
_make_state(1, 1, batter_track)
|
||||
result = _eval(1, 1)
|
||||
assert set(result.keys()) == {
|
||||
"player_id",
|
||||
"team_id",
|
||||
"current_tier",
|
||||
"current_value",
|
||||
"fully_evolved",
|
||||
"last_evaluated_at",
|
||||
}
|
||||
|
||||
def test_last_evaluated_at_is_iso_string(self, batter_track):
|
||||
"""last_evaluated_at is a non-empty ISO-8601 string."""
|
||||
_make_state(1, 1, batter_track)
|
||||
result = _eval(1, 1)
|
||||
ts = result["last_evaluated_at"]
|
||||
assert isinstance(ts, str) and len(ts) > 0
|
||||
# Must be parseable as a datetime
|
||||
datetime.fromisoformat(ts)
|
||||
326
tests/test_evolution_init.py
Normal file
326
tests/test_evolution_init.py
Normal file
@ -0,0 +1,326 @@
|
||||
"""
|
||||
Tests for WP-10: evolution_card_state initialization on pack opening.
|
||||
|
||||
Covers `app/services/evolution_init.py` — the `initialize_card_evolution`
|
||||
function that creates an EvolutionCardState row when a card is first acquired.
|
||||
|
||||
Test strategy:
|
||||
- Unit tests for `_determine_card_type` cover all three branches (batter,
|
||||
SP, RP/CP) using plain objects so no database round-trip is needed.
|
||||
- Integration tests run against the in-memory SQLite database (conftest.py
|
||||
autouse fixture) and exercise the full get_or_create path.
|
||||
|
||||
Why we test idempotency:
|
||||
Pack-opening can post duplicate cards (e.g. the same player ID appears in
|
||||
two separate pack insertions). The get_or_create guarantee means the second
|
||||
call must be a no-op — it must not reset current_tier/current_value of a
|
||||
card that has already started evolving.
|
||||
|
||||
Why we test cross-player isolation:
|
||||
Two different players with the same team must each get their own
|
||||
EvolutionCardState row. A bug that checked only team_id would share state
|
||||
across players, so we assert that state.player_id matches.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db_engine import (
|
||||
Cardset,
|
||||
EvolutionCardState,
|
||||
EvolutionTrack,
|
||||
Player,
|
||||
)
|
||||
from app.services.evolution_init import _determine_card_type, initialize_card_evolution
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakePlayer:
|
||||
"""Minimal stand-in for a Player instance used in unit tests.
|
||||
|
||||
We only need pos_1 for card-type determination; real FK fields are
|
||||
not required by the pure function under test.
|
||||
"""
|
||||
|
||||
def __init__(self, pos_1: str):
|
||||
self.pos_1 = pos_1
|
||||
|
||||
|
||||
def _make_player(rarity, pos_1: str) -> Player:
|
||||
"""Create a minimal Player row with the given pos_1 value.
|
||||
|
||||
A fresh Cardset is created per call so that players are independent
|
||||
of each other and can be iterated over in separate test cases without
|
||||
FK conflicts.
|
||||
"""
|
||||
cardset = Cardset.create(
|
||||
name=f"Set-{pos_1}-{id(pos_1)}",
|
||||
description="Test",
|
||||
total_cards=1,
|
||||
)
|
||||
return Player.create(
|
||||
p_name=f"Player {pos_1}",
|
||||
rarity=rarity,
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
pos_1=pos_1,
|
||||
image="https://example.com/img.png",
|
||||
mlbclub="TST",
|
||||
franchise="TST",
|
||||
description="test",
|
||||
)
|
||||
|
||||
|
||||
def _make_track(card_type: str) -> EvolutionTrack:
|
||||
"""Create an EvolutionTrack for the given card_type.
|
||||
|
||||
Thresholds are kept small and arbitrary; the unit under test only
|
||||
cares about card_type when selecting the track.
|
||||
"""
|
||||
return EvolutionTrack.create(
|
||||
name=f"Track-{card_type}",
|
||||
card_type=card_type,
|
||||
formula="pa",
|
||||
t1_threshold=10,
|
||||
t2_threshold=40,
|
||||
t3_threshold=120,
|
||||
t4_threshold=240,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — _determine_card_type (no DB required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetermineCardType:
|
||||
"""Unit tests for _determine_card_type, the pure position-to-type mapper.
|
||||
|
||||
The function receives a Player (or any object with a pos_1 attribute) and
|
||||
returns one of the three strings 'batter', 'sp', or 'rp'. These unit
|
||||
tests use _FakePlayer so no database is touched and failures are fast.
|
||||
"""
|
||||
|
||||
def test_starting_pitcher(self):
|
||||
"""pos_1 == 'SP' maps to card_type 'sp'.
|
||||
|
||||
SP is the canonical starting-pitcher position string stored in
|
||||
Player.pos_1 by the card-creation pipeline.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("SP")) == "sp"
|
||||
|
||||
def test_relief_pitcher(self):
|
||||
"""pos_1 == 'RP' maps to card_type 'rp'.
|
||||
|
||||
Relief pitchers carry the 'RP' position flag and must follow a
|
||||
separate evolution track with lower thresholds.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("RP")) == "rp"
|
||||
|
||||
def test_closer_pitcher(self):
|
||||
"""pos_1 == 'CP' maps to card_type 'rp'.
|
||||
|
||||
Closers share the RP evolution track; the spec explicitly lists 'CP'
|
||||
as an rp-track position.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("CP")) == "rp"
|
||||
|
||||
def test_infielder_is_batter(self):
|
||||
"""pos_1 == '1B' maps to card_type 'batter'.
|
||||
|
||||
Any non-pitcher position (1B, 2B, 3B, SS, OF, C, DH, etc.) should
|
||||
fall through to the batter track.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("1B")) == "batter"
|
||||
|
||||
def test_catcher_is_batter(self):
|
||||
"""pos_1 == 'C' maps to card_type 'batter'."""
|
||||
assert _determine_card_type(_FakePlayer("C")) == "batter"
|
||||
|
||||
def test_dh_is_batter(self):
|
||||
"""pos_1 == 'DH' maps to card_type 'batter'.
|
||||
|
||||
Designated hitters have no defensive rating but accumulate batting
|
||||
stats, so they belong on the batter track.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("DH")) == "batter"
|
||||
|
||||
def test_outfielder_is_batter(self):
|
||||
"""pos_1 == 'CF' maps to card_type 'batter'."""
|
||||
assert _determine_card_type(_FakePlayer("CF")) == "batter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — initialize_card_evolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitializeCardEvolution:
|
||||
"""Integration tests for initialize_card_evolution against in-memory SQLite.
|
||||
|
||||
Each test relies on the conftest autouse fixture to get a clean database.
|
||||
We create tracks for all three card types so the function can always find
|
||||
a matching track regardless of which player position is used.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def seed_tracks(self):
|
||||
"""Create one EvolutionTrack per card_type before each test.
|
||||
|
||||
initialize_card_evolution does a DB lookup for a track matching the
|
||||
card_type. If no track exists the function must not crash (it should
|
||||
log and return None), but having tracks present lets us verify the
|
||||
happy path for all three types without repeating setup in every test.
|
||||
"""
|
||||
self.batter_track = _make_track("batter")
|
||||
self.sp_track = _make_track("sp")
|
||||
self.rp_track = _make_track("rp")
|
||||
|
||||
def test_first_card_creates_state(self, rarity, team):
|
||||
"""First acquisition creates an EvolutionCardState with zeroed values.
|
||||
|
||||
Acceptance criteria from WP-10:
|
||||
- current_tier == 0
|
||||
- current_value == 0.0
|
||||
- fully_evolved == False
|
||||
- track matches the player's card_type (batter here)
|
||||
"""
|
||||
player = _make_player(rarity, "2B")
|
||||
state = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||
|
||||
assert state is not None
|
||||
assert state.player_id == player.player_id
|
||||
assert state.team_id == team.id
|
||||
assert state.track_id == self.batter_track.id
|
||||
assert state.current_tier == 0
|
||||
assert state.current_value == 0.0
|
||||
assert state.fully_evolved is False
|
||||
|
||||
def test_duplicate_card_skips_creation(self, rarity, team):
|
||||
"""Second call for the same (player_id, team_id) is a no-op.
|
||||
|
||||
The get_or_create guarantee: if a state row already exists it must
|
||||
not be overwritten. This protects cards that have already started
|
||||
evolving — their current_tier and current_value must be preserved.
|
||||
"""
|
||||
player = _make_player(rarity, "SS")
|
||||
# First call creates the state
|
||||
state1 = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||
assert state1 is not None
|
||||
|
||||
# Simulate partial evolution progress
|
||||
state1.current_tier = 2
|
||||
state1.current_value = 250.0
|
||||
state1.save()
|
||||
|
||||
# Second call (duplicate card) must not reset progress
|
||||
state2 = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||
assert state2 is not None
|
||||
|
||||
# Exactly one row in the database
|
||||
count = (
|
||||
EvolutionCardState.select()
|
||||
.where(
|
||||
EvolutionCardState.player == player,
|
||||
EvolutionCardState.team == team,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Progress was NOT reset
|
||||
refreshed = EvolutionCardState.get_by_id(state1.id)
|
||||
assert refreshed.current_tier == 2
|
||||
assert refreshed.current_value == 250.0
|
||||
|
||||
def test_different_player_creates_new_state(self, rarity, team):
|
||||
"""Two different players on the same team each get their own state row.
|
||||
|
||||
Cross-player isolation: the (player_id, team_id) uniqueness means
|
||||
player A and player B must have separate rows even though team_id is
|
||||
the same.
|
||||
"""
|
||||
player_a = _make_player(rarity, "LF")
|
||||
player_b = _make_player(rarity, "RF")
|
||||
|
||||
state_a = initialize_card_evolution(player_a.player_id, team.id, "batter")
|
||||
state_b = initialize_card_evolution(player_b.player_id, team.id, "batter")
|
||||
|
||||
assert state_a is not None
|
||||
assert state_b is not None
|
||||
assert state_a.id != state_b.id
|
||||
assert state_a.player_id == player_a.player_id
|
||||
assert state_b.player_id == player_b.player_id
|
||||
|
||||
def test_sp_card_gets_sp_track(self, rarity, team):
|
||||
"""A starting pitcher is assigned the 'sp' EvolutionTrack.
|
||||
|
||||
Track selection is driven by card_type, which in turn comes from
|
||||
pos_1. This test passes card_type='sp' explicitly (mirroring the
|
||||
router hook that calls _determine_card_type first) and confirms the
|
||||
state links to the sp track, not the batter track.
|
||||
"""
|
||||
player = _make_player(rarity, "SP")
|
||||
state = initialize_card_evolution(player.player_id, team.id, "sp")
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.sp_track.id
|
||||
|
||||
def test_rp_card_gets_rp_track(self, rarity, team):
|
||||
"""A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack."""
|
||||
player = _make_player(rarity, "RP")
|
||||
state = initialize_card_evolution(player.player_id, team.id, "rp")
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.rp_track.id
|
||||
|
||||
def test_missing_track_returns_none(self, rarity, team):
|
||||
"""If no track exists for the card_type, the function returns None.
|
||||
|
||||
This is the safe-failure path: the function must not raise an
|
||||
exception if the evolution system is misconfigured (e.g. track seed
|
||||
data missing). It logs the problem and returns None so that the
|
||||
caller (the cards router) can proceed with pack opening unaffected.
|
||||
|
||||
We use a fictional card_type that has no matching seed row.
|
||||
"""
|
||||
player = _make_player(rarity, "SP")
|
||||
# Delete the sp track to simulate missing seed data
|
||||
self.sp_track.delete_instance()
|
||||
|
||||
result = initialize_card_evolution(player.player_id, team.id, "sp")
|
||||
assert result is None
|
||||
|
||||
def test_card_type_from_pos1_batter(self, rarity, team):
|
||||
"""_determine_card_type is wired correctly for a batter position.
|
||||
|
||||
End-to-end: pass the player object directly and verify the state
|
||||
ends up on the batter track based solely on pos_1.
|
||||
"""
|
||||
player = _make_player(rarity, "3B")
|
||||
card_type = _determine_card_type(player)
|
||||
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.batter_track.id
|
||||
|
||||
def test_card_type_from_pos1_sp(self, rarity, team):
|
||||
"""_determine_card_type is wired correctly for a starting pitcher."""
|
||||
player = _make_player(rarity, "SP")
|
||||
card_type = _determine_card_type(player)
|
||||
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.sp_track.id
|
||||
|
||||
def test_card_type_from_pos1_rp(self, rarity, team):
|
||||
"""_determine_card_type correctly routes CP to the rp track."""
|
||||
player = _make_player(rarity, "CP")
|
||||
card_type = _determine_card_type(player)
|
||||
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.rp_track.id
|
||||
328
tests/test_evolution_models.py
Normal file
328
tests/test_evolution_models.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""
|
||||
Tests for evolution-related models and BattingSeasonStats.
|
||||
|
||||
Covers WP-01 acceptance criteria:
|
||||
- EvolutionTrack: CRUD and unique-name constraint
|
||||
- EvolutionCardState: CRUD, defaults, unique-(player,team) constraint,
|
||||
and FK resolution back to EvolutionTrack
|
||||
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
|
||||
- EvolutionCosmetic: CRUD and unique-name constraint
|
||||
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
|
||||
and in-place stat accumulation
|
||||
|
||||
Each test class is self-contained: fixtures from conftest.py supply the
|
||||
minimal parent rows needed to satisfy FK constraints, and every assertion
|
||||
targets a single, clearly-named behaviour so failures are easy to trace.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from peewee import IntegrityError
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from app.db_engine import (
|
||||
BattingSeasonStats,
|
||||
EvolutionCardState,
|
||||
EvolutionCosmetic,
|
||||
EvolutionTierBoost,
|
||||
EvolutionTrack,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionTrack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionTrack:
|
||||
"""Tests for the EvolutionTrack model.
|
||||
|
||||
EvolutionTrack defines a named progression path (formula +
|
||||
tier thresholds) for a card type. The name column carries a
|
||||
UNIQUE constraint so that accidental duplicates are caught at
|
||||
the database level.
|
||||
"""
|
||||
|
||||
def test_create_track(self, track):
|
||||
"""Creating a track persists all fields and they round-trip correctly.
|
||||
|
||||
Reads back via model_to_dict (recurse=False) to verify the raw
|
||||
column values, not Python-object representations, match what was
|
||||
inserted.
|
||||
"""
|
||||
data = model_to_dict(track, recurse=False)
|
||||
assert data["name"] == "Batter Track"
|
||||
assert data["card_type"] == "batter"
|
||||
assert data["formula"] == "pa + tb * 2"
|
||||
assert data["t1_threshold"] == 37
|
||||
assert data["t2_threshold"] == 149
|
||||
assert data["t3_threshold"] == 448
|
||||
assert data["t4_threshold"] == 896
|
||||
|
||||
def test_track_unique_name(self, track):
|
||||
"""Inserting a second track with the same name raises IntegrityError.
|
||||
|
||||
The UNIQUE constraint on EvolutionTrack.name must prevent two
|
||||
tracks from sharing the same identifier, as the name is used as
|
||||
a human-readable key throughout the evolution system.
|
||||
"""
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionTrack.create(
|
||||
name="Batter Track", # duplicate
|
||||
card_type="sp",
|
||||
formula="outs * 3",
|
||||
t1_threshold=10,
|
||||
t2_threshold=40,
|
||||
t3_threshold=120,
|
||||
t4_threshold=240,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionCardState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionCardState:
|
||||
"""Tests for EvolutionCardState, which tracks per-player evolution progress.
|
||||
|
||||
Each row represents one card (player) owned by one team, linked to a
|
||||
specific EvolutionTrack. The model records the current tier (0-4),
|
||||
accumulated progress value, and whether the card is fully evolved.
|
||||
"""
|
||||
|
||||
def test_create_card_state(self, player, team, track):
|
||||
"""Creating a card state stores all fields and defaults are correct.
|
||||
|
||||
Defaults under test:
|
||||
current_tier → 0 (fresh card, no tier unlocked yet)
|
||||
current_value → 0.0 (no formula progress accumulated)
|
||||
fully_evolved → False (evolution is not complete at creation)
|
||||
last_evaluated_at → None (never evaluated yet)
|
||||
"""
|
||||
state = EvolutionCardState.create(player=player, team=team, track=track)
|
||||
|
||||
fetched = EvolutionCardState.get_by_id(state.id)
|
||||
assert fetched.player_id == player.player_id
|
||||
assert fetched.team_id == team.id
|
||||
assert fetched.track_id == track.id
|
||||
assert fetched.current_tier == 0
|
||||
assert fetched.current_value == 0.0
|
||||
assert fetched.fully_evolved is False
|
||||
assert fetched.last_evaluated_at is None
|
||||
|
||||
def test_card_state_unique_player_team(self, player, team, track):
|
||||
"""A second card state for the same (player, team) pair raises IntegrityError.
|
||||
|
||||
The unique index on (player, team) enforces that each player card
|
||||
has at most one evolution state per team roster slot, preventing
|
||||
duplicate evolution progress rows for the same physical card.
|
||||
"""
|
||||
EvolutionCardState.create(player=player, team=team, track=track)
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionCardState.create(player=player, team=team, track=track)
|
||||
|
||||
def test_card_state_fk_track(self, player, team, track):
|
||||
"""Accessing card_state.track returns the original EvolutionTrack instance.
|
||||
|
||||
This confirms the FK is correctly wired and that Peewee resolves
|
||||
the relationship, returning an object with the same primary key and
|
||||
name as the track used during creation.
|
||||
"""
|
||||
state = EvolutionCardState.create(player=player, team=team, track=track)
|
||||
fetched = EvolutionCardState.get_by_id(state.id)
|
||||
resolved_track = fetched.track
|
||||
assert resolved_track.id == track.id
|
||||
assert resolved_track.name == "Batter Track"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionTierBoost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionTierBoost:
|
||||
"""Tests for EvolutionTierBoost, the per-tier stat/rating bonus table.
|
||||
|
||||
Each row maps a (track, tier) combination to a single boost — the
|
||||
specific stat or rating column to buff and by how much. The four-
|
||||
column unique constraint prevents double-booking the same boost slot.
|
||||
"""
|
||||
|
||||
def test_create_tier_boost(self, track):
|
||||
"""Creating a boost row persists all fields accurately.
|
||||
|
||||
Verifies boost_type, boost_target, and boost_value are stored
|
||||
and retrieved without modification.
|
||||
"""
|
||||
boost = EvolutionTierBoost.create(
|
||||
track=track,
|
||||
tier=1,
|
||||
boost_type="rating",
|
||||
boost_target="contact_vl",
|
||||
boost_value=1.5,
|
||||
)
|
||||
fetched = EvolutionTierBoost.get_by_id(boost.id)
|
||||
assert fetched.track_id == track.id
|
||||
assert fetched.tier == 1
|
||||
assert fetched.boost_type == "rating"
|
||||
assert fetched.boost_target == "contact_vl"
|
||||
assert fetched.boost_value == 1.5
|
||||
|
||||
def test_tier_boost_unique_constraint(self, track):
|
||||
"""Duplicate (track, tier, boost_type, boost_target) raises IntegrityError.
|
||||
|
||||
The four-column unique index ensures that a single boost slot
|
||||
(e.g. Tier-1 contact_vl rating) cannot be defined twice for the
|
||||
same track, which would create ambiguity during evolution evaluation.
|
||||
"""
|
||||
EvolutionTierBoost.create(
|
||||
track=track,
|
||||
tier=2,
|
||||
boost_type="rating",
|
||||
boost_target="power_vr",
|
||||
boost_value=2.0,
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionTierBoost.create(
|
||||
track=track,
|
||||
tier=2,
|
||||
boost_type="rating",
|
||||
boost_target="power_vr",
|
||||
boost_value=3.0, # different value, same identity columns
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionCosmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionCosmetic:
|
||||
"""Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers.
|
||||
|
||||
Cosmetics are purely visual rewards (frames, badges, themes) that a
|
||||
card unlocks when it reaches a required tier. The name column is
|
||||
the stable identifier and carries a UNIQUE constraint.
|
||||
"""
|
||||
|
||||
def test_create_cosmetic(self):
|
||||
"""Creating a cosmetic persists all fields correctly.
|
||||
|
||||
Verifies all columns including optional ones (css_class, asset_url)
|
||||
are stored and retrieved.
|
||||
"""
|
||||
cosmetic = EvolutionCosmetic.create(
|
||||
name="Gold Frame",
|
||||
tier_required=2,
|
||||
cosmetic_type="frame",
|
||||
css_class="evo-frame-gold",
|
||||
asset_url="https://cdn.example.com/frames/gold.png",
|
||||
)
|
||||
fetched = EvolutionCosmetic.get_by_id(cosmetic.id)
|
||||
assert fetched.name == "Gold Frame"
|
||||
assert fetched.tier_required == 2
|
||||
assert fetched.cosmetic_type == "frame"
|
||||
assert fetched.css_class == "evo-frame-gold"
|
||||
assert fetched.asset_url == "https://cdn.example.com/frames/gold.png"
|
||||
|
||||
def test_cosmetic_unique_name(self):
|
||||
"""Inserting a second cosmetic with the same name raises IntegrityError.
|
||||
|
||||
The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate
|
||||
cosmetic definitions that could cause ambiguous tier unlock lookups.
|
||||
"""
|
||||
EvolutionCosmetic.create(
|
||||
name="Silver Badge",
|
||||
tier_required=1,
|
||||
cosmetic_type="badge",
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionCosmetic.create(
|
||||
name="Silver Badge", # duplicate
|
||||
tier_required=3,
|
||||
cosmetic_type="badge",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BattingSeasonStats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBattingSeasonStats:
|
||||
"""Tests for BattingSeasonStats, the per-season batting accumulation table.
|
||||
|
||||
Each row aggregates game-by-game batting stats for one player on one
|
||||
team in one season. The three-column unique constraint prevents
|
||||
double-counting and ensures a single authoritative row for each
|
||||
(player, team, season) combination.
|
||||
"""
|
||||
|
||||
def test_create_season_stats(self, player, team):
|
||||
"""Creating a stats row with explicit values stores everything correctly.
|
||||
|
||||
Also verifies the integer stat defaults (all 0) for columns that
|
||||
are not provided, which is the initial state before any games are
|
||||
processed.
|
||||
"""
|
||||
stats = BattingSeasonStats.create(
|
||||
player=player,
|
||||
team=team,
|
||||
season=11,
|
||||
games=5,
|
||||
pa=20,
|
||||
ab=18,
|
||||
hits=6,
|
||||
doubles=1,
|
||||
triples=0,
|
||||
hr=2,
|
||||
bb=2,
|
||||
hbp=0,
|
||||
strikeouts=4,
|
||||
rbi=5,
|
||||
runs=3,
|
||||
sb=1,
|
||||
cs=0,
|
||||
)
|
||||
fetched = BattingSeasonStats.get_by_id(stats.id)
|
||||
assert fetched.player_id == player.player_id
|
||||
assert fetched.team_id == team.id
|
||||
assert fetched.season == 11
|
||||
assert fetched.games == 5
|
||||
assert fetched.pa == 20
|
||||
assert fetched.hits == 6
|
||||
assert fetched.hr == 2
|
||||
assert fetched.strikeouts == 4
|
||||
# Nullable meta fields
|
||||
assert fetched.last_game is None
|
||||
assert fetched.last_updated_at is None
|
||||
|
||||
def test_season_stats_unique_constraint(self, player, team):
|
||||
"""A second row for the same (player, team, season) raises IntegrityError.
|
||||
|
||||
The unique index on these three columns guarantees that each
|
||||
player-team-season combination has exactly one accumulation row,
|
||||
preventing duplicate stat aggregation that would inflate totals.
|
||||
"""
|
||||
BattingSeasonStats.create(player=player, team=team, season=11)
|
||||
with pytest.raises(IntegrityError):
|
||||
BattingSeasonStats.create(player=player, team=team, season=11)
|
||||
|
||||
def test_season_stats_increment(self, player, team):
|
||||
"""Manually incrementing hits on an existing row persists the change.
|
||||
|
||||
Simulates the common pattern used by the stats accumulator:
|
||||
fetch the row, add the game delta, save. Verifies that save()
|
||||
writes back to the database and that subsequent reads reflect the
|
||||
updated value.
|
||||
"""
|
||||
stats = BattingSeasonStats.create(
|
||||
player=player,
|
||||
team=team,
|
||||
season=11,
|
||||
hits=10,
|
||||
)
|
||||
stats.hits += 3
|
||||
stats.save()
|
||||
|
||||
refreshed = BattingSeasonStats.get_by_id(stats.id)
|
||||
assert refreshed.hits == 13
|
||||
159
tests/test_evolution_seed.py
Normal file
159
tests/test_evolution_seed.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
Tests for app/seed/evolution_tracks.py — seed_evolution_tracks().
|
||||
|
||||
What: Verify that the JSON-driven seed function correctly creates, counts,
|
||||
and idempotently updates EvolutionTrack rows in the database.
|
||||
|
||||
Why: The seed is the single source of truth for track configuration. A
|
||||
regression here (duplicates, wrong thresholds, missing formula) would
|
||||
silently corrupt evolution scoring for every card in the system.
|
||||
|
||||
Each test operates on a fresh in-memory SQLite database provided by the
|
||||
autouse `setup_test_db` fixture in conftest.py. The seed reads its data
|
||||
from `app/seed/evolution_tracks.json` on disk, so the tests also serve as
|
||||
a light integration check between the JSON file and the Peewee model.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db_engine import EvolutionTrack
|
||||
from app.seed.evolution_tracks import seed_evolution_tracks
|
||||
|
||||
# Path to the JSON fixture that the seed reads from at runtime
|
||||
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def json_tracks():
|
||||
"""Load the raw JSON definitions so tests can assert against them.
|
||||
|
||||
This avoids hardcoding expected values — if the JSON changes, tests
|
||||
automatically follow without needing manual updates.
|
||||
"""
|
||||
return json.loads(_JSON_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_seed_creates_three_tracks(json_tracks):
|
||||
"""After one seed call, exactly 3 EvolutionTrack rows must exist.
|
||||
|
||||
Why: The JSON currently defines three card-type tracks (batter, sp, rp).
|
||||
If the count is wrong the system would either be missing tracks
|
||||
(evolution disabled for a card type) or have phantom extras.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
assert EvolutionTrack.select().count() == 3
|
||||
|
||||
|
||||
def test_seed_correct_card_types(json_tracks):
|
||||
"""The set of card_type values persisted must match the JSON exactly.
|
||||
|
||||
Why: card_type is used as a discriminator throughout the evolution engine.
|
||||
An unexpected value (e.g. 'pitcher' instead of 'sp') would cause
|
||||
track-lookup misses and silently skip evolution scoring for that role.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
expected_types = {d["card_type"] for d in json_tracks}
|
||||
actual_types = {t.card_type for t in EvolutionTrack.select()}
|
||||
assert actual_types == expected_types
|
||||
|
||||
|
||||
def test_seed_thresholds_ascending():
|
||||
"""For every track, t1 < t2 < t3 < t4.
|
||||
|
||||
Why: The evolution engine uses these thresholds to determine tier
|
||||
boundaries. If they are not strictly ascending, tier comparisons
|
||||
would produce incorrect or undefined results (e.g. a player could
|
||||
simultaneously satisfy tier 3 and not satisfy tier 2).
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
for track in EvolutionTrack.select():
|
||||
assert (
|
||||
track.t1_threshold < track.t2_threshold
|
||||
), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})"
|
||||
assert (
|
||||
track.t2_threshold < track.t3_threshold
|
||||
), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})"
|
||||
assert (
|
||||
track.t3_threshold < track.t4_threshold
|
||||
), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})"
|
||||
|
||||
|
||||
def test_seed_thresholds_positive():
|
||||
"""All tier threshold values must be strictly greater than zero.
|
||||
|
||||
Why: A zero or negative threshold would mean a card starts the game
|
||||
already evolved (tier >= 1 at 0 accumulated stat points), which would
|
||||
bypass the entire progression system.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
for track in EvolutionTrack.select():
|
||||
assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive"
|
||||
assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive"
|
||||
assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive"
|
||||
assert track.t4_threshold > 0, f"{track.name}: t4_threshold is not positive"
|
||||
|
||||
|
||||
def test_seed_formula_present():
|
||||
"""Every persisted track must have a non-empty formula string.
|
||||
|
||||
Why: The formula is evaluated at runtime to compute a player's evolution
|
||||
score. An empty formula would cause either a Python eval error or
|
||||
silently produce 0 for every player, halting all evolution progress.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
for track in EvolutionTrack.select():
|
||||
assert (
|
||||
track.formula and track.formula.strip()
|
||||
), f"{track.name}: formula is empty or whitespace-only"
|
||||
|
||||
|
||||
def test_seed_idempotent():
|
||||
"""Calling seed_evolution_tracks() twice must still yield exactly 3 rows.
|
||||
|
||||
Why: The seed is designed to be safe to re-run (e.g. as part of a
|
||||
migration or CI bootstrap). If it inserts duplicates on a second call,
|
||||
the unique constraint on EvolutionTrack.name would raise an IntegrityError
|
||||
in PostgreSQL, and in SQLite it would silently create phantom rows that
|
||||
corrupt tier-lookup joins.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
seed_evolution_tracks()
|
||||
assert EvolutionTrack.select().count() == 3
|
||||
|
||||
|
||||
def test_seed_updates_on_rerun(json_tracks):
|
||||
"""A second seed call must restore any manually changed threshold to the JSON value.
|
||||
|
||||
What: Seed once, manually mutate a threshold in the DB, then seed again.
|
||||
Assert that the threshold is now back to the JSON-defined value.
|
||||
|
||||
Why: The seed must act as the authoritative source of truth. If
|
||||
re-seeding does not overwrite local changes, configuration drift can
|
||||
build up silently and the production database would diverge from the
|
||||
checked-in JSON without any visible error.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
|
||||
# Pick the first track and corrupt its t1_threshold
|
||||
first_def = json_tracks[0]
|
||||
track = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
|
||||
original_t1 = track.t1_threshold
|
||||
corrupted_value = original_t1 + 9999
|
||||
track.t1_threshold = corrupted_value
|
||||
track.save()
|
||||
|
||||
# Confirm the corruption took effect before re-seeding
|
||||
track_check = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
|
||||
assert track_check.t1_threshold == corrupted_value
|
||||
|
||||
# Re-seed — should restore the JSON value
|
||||
seed_evolution_tracks()
|
||||
|
||||
restored = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
|
||||
assert restored.t1_threshold == first_def["t1_threshold"], (
|
||||
f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, "
|
||||
f"got {restored.t1_threshold}"
|
||||
)
|
||||
609
tests/test_evolution_state_api.py
Normal file
609
tests/test_evolution_state_api.py
Normal file
@ -0,0 +1,609 @@
|
||||
"""Integration tests for the evolution card state API endpoints (WP-07).
|
||||
|
||||
Tests cover:
|
||||
GET /api/v2/teams/{team_id}/evolutions
|
||||
GET /api/v2/evolution/cards/{card_id}
|
||||
|
||||
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and
|
||||
assume the evolution schema migration (WP-04) has already been applied.
|
||||
Tests auto-skip when POSTGRES_HOST is not set.
|
||||
|
||||
Test data is inserted via psycopg2 before each module fixture runs and
|
||||
cleaned up in teardown so the tests are repeatable. ON CONFLICT / CASCADE
|
||||
clauses keep the table clean even if a previous run did not complete teardown.
|
||||
|
||||
Object graph built by fixtures
|
||||
-------------------------------
|
||||
rarity_row -- a seeded rarity row
|
||||
cardset_row -- a seeded cardset row
|
||||
player_row -- a seeded player row (FK: rarity, cardset)
|
||||
team_row -- a seeded team row
|
||||
track_row -- a seeded evolution_track row (batter)
|
||||
card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset)
|
||||
state_row -- a seeded evolution_card_state row (FK: player, team, track)
|
||||
|
||||
Test matrix
|
||||
-----------
|
||||
test_list_team_evolutions -- baseline: returns count + items for a team
|
||||
test_list_filter_by_card_type -- card_type query param filters by track.card_type
|
||||
test_list_filter_by_tier -- tier query param filters by current_tier
|
||||
test_list_pagination -- page/per_page params slice results correctly
|
||||
test_get_card_state_shape -- single card returns all required response fields
|
||||
test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current
|
||||
test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState
|
||||
test_get_card_404_no_state -- card with no EvolutionCardState returns 404
|
||||
test_duplicate_cards_share_state -- two cards same player+team return the same state row
|
||||
test_auth_required -- missing token returns 401 on both endpoints
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
||||
_skip_no_pg = pytest.mark.skipif(
|
||||
not POSTGRES_HOST, reason="POSTGRES_HOST not set — integration tests skipped"
|
||||
)
|
||||
|
||||
AUTH_HEADER = {"Authorization": f"Bearer {os.environ.get('API_TOKEN', 'test-token')}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures: seed and clean up the full object graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_data(pg_conn):
|
||||
"""Insert all rows needed for state API tests; delete them after the module.
|
||||
|
||||
Returns a dict with the integer IDs of every inserted row so individual
|
||||
test functions can reference them by key.
|
||||
|
||||
Insertion order respects FK dependencies:
|
||||
rarity -> cardset -> player
|
||||
pack_type (needs cardset) -> pack (needs team + pack_type) -> card
|
||||
evolution_track -> evolution_card_state
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# Rarity
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO rarity (value, name, color)
|
||||
VALUES (99, 'WP07TestRarity', '#123456')
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
rarity_id = cur.fetchone()[0]
|
||||
|
||||
# Cardset
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO cardset (name, description, total_cards)
|
||||
VALUES ('WP07 Test Set', 'evo state api tests', 1)
|
||||
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
cardset_id = cur.fetchone()[0]
|
||||
|
||||
# Player 1 (batter)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
|
||||
image, mlbclub, franchise, description)
|
||||
VALUES ('WP07 Batter', %s, %s, 901, '1B',
|
||||
'https://example.com/wp07_b.png', 'TST', 'TST', 'wp07 test batter')
|
||||
RETURNING player_id
|
||||
""",
|
||||
(rarity_id, cardset_id),
|
||||
)
|
||||
player_id = cur.fetchone()[0]
|
||||
|
||||
# Player 2 (sp) for cross-card_type filter test
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
|
||||
image, mlbclub, franchise, description)
|
||||
VALUES ('WP07 Pitcher', %s, %s, 902, 'SP',
|
||||
'https://example.com/wp07_p.png', 'TST', 'TST', 'wp07 test pitcher')
|
||||
RETURNING player_id
|
||||
""",
|
||||
(rarity_id, cardset_id),
|
||||
)
|
||||
player2_id = cur.fetchone()[0]
|
||||
|
||||
# Team
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO team (abbrev, sname, lname, gmid, gmname, gsheet,
|
||||
wallet, team_value, collection_value, season, is_ai)
|
||||
VALUES ('WP7', 'WP07', 'WP07 Test Team', 700000001, 'wp07user',
|
||||
'https://docs.google.com/wp07', 0, 0, 0, 11, false)
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
team_id = cur.fetchone()[0]
|
||||
|
||||
# Evolution tracks
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_track (name, card_type, formula,
|
||||
t1_threshold, t2_threshold,
|
||||
t3_threshold, t4_threshold)
|
||||
VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896)
|
||||
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
batter_track_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_track (name, card_type, formula,
|
||||
t1_threshold, t2_threshold,
|
||||
t3_threshold, t4_threshold)
|
||||
VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240)
|
||||
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
sp_track_id = cur.fetchone()[0]
|
||||
|
||||
# Pack type + pack (needed as FK parent for Card)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack_type (name, cost, card_count, cardset_id)
|
||||
VALUES ('WP07 Pack Type', 100, 5, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(cardset_id,),
|
||||
)
|
||||
pack_type_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack (team_id, pack_type_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(team_id, pack_type_id),
|
||||
)
|
||||
pack_id = cur.fetchone()[0]
|
||||
|
||||
# Card linking batter player to team
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||
VALUES (%s, %s, %s, 0)
|
||||
RETURNING id
|
||||
""",
|
||||
(player_id, team_id, pack_id),
|
||||
)
|
||||
card_id = cur.fetchone()[0]
|
||||
|
||||
# Second card for same player+team (shared-state test)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack (team_id, pack_type_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(team_id, pack_type_id),
|
||||
)
|
||||
pack2_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||
VALUES (%s, %s, %s, 0)
|
||||
RETURNING id
|
||||
""",
|
||||
(player_id, team_id, pack2_id),
|
||||
)
|
||||
card2_id = cur.fetchone()[0]
|
||||
|
||||
# Card with NO state (404 test)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack (team_id, pack_type_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(team_id, pack_type_id),
|
||||
)
|
||||
pack3_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||
VALUES (%s, %s, %s, 0)
|
||||
RETURNING id
|
||||
""",
|
||||
(player2_id, team_id, pack3_id),
|
||||
)
|
||||
card_no_state_id = cur.fetchone()[0]
|
||||
|
||||
# Evolution card states
|
||||
# Batter player at tier 1, value 87.5
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_card_state
|
||||
(player_id, team_id, track_id, current_tier, current_value,
|
||||
fully_evolved, last_evaluated_at)
|
||||
VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z')
|
||||
RETURNING id
|
||||
""",
|
||||
(player_id, team_id, batter_track_id),
|
||||
)
|
||||
state_id = cur.fetchone()[0]
|
||||
|
||||
pg_conn.commit()
|
||||
|
||||
yield {
|
||||
"rarity_id": rarity_id,
|
||||
"cardset_id": cardset_id,
|
||||
"player_id": player_id,
|
||||
"player2_id": player2_id,
|
||||
"team_id": team_id,
|
||||
"batter_track_id": batter_track_id,
|
||||
"sp_track_id": sp_track_id,
|
||||
"pack_type_id": pack_type_id,
|
||||
"card_id": card_id,
|
||||
"card2_id": card2_id,
|
||||
"card_no_state_id": card_no_state_id,
|
||||
"state_id": state_id,
|
||||
}
|
||||
|
||||
# Teardown: delete in reverse FK order
|
||||
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (state_id,))
|
||||
cur.execute(
|
||||
"DELETE FROM card WHERE id = ANY(%s)",
|
||||
([card_id, card2_id, card_no_state_id],),
|
||||
)
|
||||
cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],))
|
||||
cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,))
|
||||
cur.execute(
|
||||
"DELETE FROM evolution_track WHERE id = ANY(%s)",
|
||||
([batter_track_id, sp_track_id],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM player WHERE player_id = ANY(%s)", ([player_id, player2_id],)
|
||||
)
|
||||
cur.execute("DELETE FROM team WHERE id = %s", (team_id,))
|
||||
cur.execute("DELETE FROM cardset WHERE id = %s", (cardset_id,))
|
||||
cur.execute("DELETE FROM rarity WHERE id = %s", (rarity_id,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""FastAPI TestClient backed by the real PostgreSQL database."""
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: GET /api/v2/teams/{team_id}/evolutions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_team_evolutions(client, seeded_data):
|
||||
"""GET /teams/{id}/evolutions returns count=1 and one item for the seeded state.
|
||||
|
||||
Verifies the basic list response shape: a dict with 'count' and 'items',
|
||||
and that the single item contains player_id, team_id, and current_tier.
|
||||
"""
|
||||
team_id = seeded_data["team_id"]
|
||||
resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
item = data["items"][0]
|
||||
assert item["player_id"] == seeded_data["player_id"]
|
||||
assert item["team_id"] == team_id
|
||||
assert item["current_tier"] == 1
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_filter_by_card_type(client, seeded_data, pg_conn):
|
||||
"""card_type filter includes states whose track.card_type matches and excludes others.
|
||||
|
||||
Seeds a second evolution_card_state for player2 (sp track) then queries
|
||||
card_type=batter (returns 1) and card_type=sp (returns 1).
|
||||
Verifies the JOIN to evolution_track and the WHERE predicate on card_type.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
# Add a state for the sp player so we have two types in this team
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_card_state
|
||||
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
|
||||
VALUES (%s, %s, %s, 0, 0.0, false)
|
||||
RETURNING id
|
||||
""",
|
||||
(seeded_data["player2_id"], seeded_data["team_id"], seeded_data["sp_track_id"]),
|
||||
)
|
||||
sp_state_id = cur.fetchone()[0]
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
team_id = seeded_data["team_id"]
|
||||
|
||||
resp_batter = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_batter.status_code == 200
|
||||
batter_data = resp_batter.json()
|
||||
assert batter_data["count"] == 1
|
||||
assert batter_data["items"][0]["player_id"] == seeded_data["player_id"]
|
||||
|
||||
resp_sp = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_sp.status_code == 200
|
||||
sp_data = resp_sp.json()
|
||||
assert sp_data["count"] == 1
|
||||
assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"]
|
||||
finally:
|
||||
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_filter_by_tier(client, seeded_data, pg_conn):
|
||||
"""tier filter includes only states at the specified current_tier.
|
||||
|
||||
The base fixture has player1 at tier=1. This test temporarily advances
|
||||
it to tier=2, then queries tier=1 (should return 0) and tier=2 (should
|
||||
return 1). Restores to tier=1 after assertions.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# Advance to tier 2
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s",
|
||||
(seeded_data["state_id"],),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
team_id = seeded_data["team_id"]
|
||||
|
||||
resp_t1 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_t1.status_code == 200
|
||||
assert resp_t1.json()["count"] == 0
|
||||
|
||||
resp_t2 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_t2.status_code == 200
|
||||
t2_data = resp_t2.json()
|
||||
assert t2_data["count"] == 1
|
||||
assert t2_data["items"][0]["current_tier"] == 2
|
||||
finally:
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s",
|
||||
(seeded_data["state_id"],),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_pagination(client, seeded_data, pg_conn):
|
||||
"""page/per_page params slice the full result set correctly.
|
||||
|
||||
Temporarily inserts a second state (for player2 on the same team) so
|
||||
the list has 2 items. With per_page=1, page=1 returns item 1 and
|
||||
page=2 returns item 2; they must be different players.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_card_state
|
||||
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
|
||||
VALUES (%s, %s, %s, 0, 0.0, false)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
seeded_data["player2_id"],
|
||||
seeded_data["team_id"],
|
||||
seeded_data["batter_track_id"],
|
||||
),
|
||||
)
|
||||
extra_state_id = cur.fetchone()[0]
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
team_id = seeded_data["team_id"]
|
||||
|
||||
resp1 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
data1 = resp1.json()
|
||||
assert len(data1["items"]) == 1
|
||||
|
||||
resp2 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert len(data2["items"]) == 1
|
||||
|
||||
assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"]
|
||||
finally:
|
||||
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: GET /api/v2/evolution/cards/{card_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_state_shape(client, seeded_data):
|
||||
"""GET /evolution/cards/{card_id} returns all required fields.
|
||||
|
||||
Verifies the full response envelope:
|
||||
player_id, team_id, current_tier, current_value, fully_evolved,
|
||||
last_evaluated_at, next_threshold, and a nested 'track' dict
|
||||
with id, name, card_type, formula, and t1-t4 thresholds.
|
||||
"""
|
||||
card_id = seeded_data["card_id"]
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["player_id"] == seeded_data["player_id"]
|
||||
assert data["team_id"] == seeded_data["team_id"]
|
||||
assert data["current_tier"] == 1
|
||||
assert data["current_value"] == 87.5
|
||||
assert data["fully_evolved"] is False
|
||||
|
||||
t = data["track"]
|
||||
assert t["id"] == seeded_data["batter_track_id"]
|
||||
assert t["name"] == "WP07 Batter Track"
|
||||
assert t["card_type"] == "batter"
|
||||
assert t["formula"] == "pa + tb * 2"
|
||||
assert t["t1_threshold"] == 37
|
||||
assert t["t2_threshold"] == 149
|
||||
assert t["t3_threshold"] == 448
|
||||
assert t["t4_threshold"] == 896
|
||||
|
||||
# tier=1 -> next threshold is t2_threshold
|
||||
assert data["next_threshold"] == 149
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
|
||||
"""next_threshold reflects the threshold for the tier immediately above current.
|
||||
|
||||
Tier mapping:
|
||||
0 -> t1_threshold (37)
|
||||
1 -> t2_threshold (149)
|
||||
2 -> t3_threshold (448)
|
||||
3 -> t4_threshold (896)
|
||||
4 -> null (fully evolved)
|
||||
|
||||
This test advances the state to tier=2, confirms next_threshold=448,
|
||||
then to tier=4 (fully_evolved=True) and confirms next_threshold=null.
|
||||
Restores original state after assertions.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
card_id = seeded_data["card_id"]
|
||||
state_id = seeded_data["state_id"]
|
||||
|
||||
# Advance to tier 2
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,)
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["next_threshold"] == 448 # t3_threshold
|
||||
|
||||
# Advance to tier 4 (fully evolved)
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true "
|
||||
"WHERE id = %s",
|
||||
(state_id,),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["next_threshold"] is None
|
||||
finally:
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false "
|
||||
"WHERE id = %s",
|
||||
(state_id,),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_id_resolves_player(client, seeded_data):
|
||||
"""card_id is resolved via the Card table to obtain (player_id, team_id).
|
||||
|
||||
The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState.
|
||||
Verifies that card_id correctly maps to the right player's evolution state.
|
||||
"""
|
||||
card_id = seeded_data["card_id"]
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["player_id"] == seeded_data["player_id"]
|
||||
assert data["team_id"] == seeded_data["team_id"]
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_404_no_state(client, seeded_data):
|
||||
"""GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists.
|
||||
|
||||
card_no_state_id is a card row for player2 on the team, but no
|
||||
evolution_card_state row was created for player2. The endpoint must
|
||||
return 404, not 500 or an empty response.
|
||||
"""
|
||||
card_id = seeded_data["card_no_state_id"]
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_duplicate_cards_share_state(client, seeded_data):
|
||||
"""Two Card rows for the same player+team share one EvolutionCardState.
|
||||
|
||||
card_id and card2_id both belong to player_id on team_id. Because the
|
||||
unique-(player,team) constraint means only one state row can exist, both
|
||||
card IDs must resolve to the same state data.
|
||||
"""
|
||||
card1_id = seeded_data["card_id"]
|
||||
card2_id = seeded_data["card2_id"]
|
||||
|
||||
resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER)
|
||||
resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER)
|
||||
|
||||
assert resp1.status_code == 200
|
||||
assert resp2.status_code == 200
|
||||
data1 = resp1.json()
|
||||
data2 = resp2.json()
|
||||
|
||||
assert data1["player_id"] == data2["player_id"] == seeded_data["player_id"]
|
||||
assert data1["current_tier"] == data2["current_tier"] == 1
|
||||
assert data1["current_value"] == data2["current_value"] == 87.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_auth_required(client, seeded_data):
|
||||
"""Both endpoints return 401 when no Bearer token is provided.
|
||||
|
||||
Verifies that the valid_token dependency is enforced on:
|
||||
GET /api/v2/teams/{id}/evolutions
|
||||
GET /api/v2/evolution/cards/{id}
|
||||
"""
|
||||
team_id = seeded_data["team_id"]
|
||||
card_id = seeded_data["card_id"]
|
||||
|
||||
resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions")
|
||||
assert resp_list.status_code == 401
|
||||
|
||||
resp_card = client.get(f"/api/v2/evolution/cards/{card_id}")
|
||||
assert resp_card.status_code == 401
|
||||
132
tests/test_evolution_track_api.py
Normal file
132
tests/test_evolution_track_api.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Integration tests for the evolution track catalog API endpoints (WP-06).
|
||||
|
||||
Tests cover:
|
||||
GET /api/v2/evolution/tracks
|
||||
GET /api/v2/evolution/tracks/{track_id}
|
||||
|
||||
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and
|
||||
assume the evolution schema migration (WP-04) has already been applied.
|
||||
Tests auto-skip when POSTGRES_HOST is not set.
|
||||
|
||||
Test data is inserted via psycopg2 before the test module runs and deleted
|
||||
afterwards so the tests are repeatable. ON CONFLICT keeps the table clean
|
||||
even if a previous run did not complete teardown.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
||||
_skip_no_pg = pytest.mark.skipif(
|
||||
not POSTGRES_HOST, reason="POSTGRES_HOST not set — integration tests skipped"
|
||||
)
|
||||
|
||||
AUTH_HEADER = {"Authorization": f"Bearer {os.environ.get('API_TOKEN', 'test-token')}"}
|
||||
|
||||
_SEED_TRACKS = [
|
||||
("Batter", "batter", "pa+tb*2", 37, 149, 448, 896),
|
||||
("Starting Pitcher", "sp", "ip+k", 10, 40, 120, 240),
|
||||
("Relief Pitcher", "rp", "ip+k", 3, 12, 35, 70),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_tracks(pg_conn):
|
||||
"""Insert three canonical evolution tracks; remove them after the module.
|
||||
|
||||
Uses ON CONFLICT DO UPDATE so the fixture is safe to run even if rows
|
||||
already exist from a prior test run that did not clean up. Returns the
|
||||
list of row IDs that were upserted.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
ids = []
|
||||
for name, card_type, formula, t1, t2, t3, t4 in _SEED_TRACKS:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_track
|
||||
(name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (card_type) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
formula = EXCLUDED.formula,
|
||||
t1_threshold = EXCLUDED.t1_threshold,
|
||||
t2_threshold = EXCLUDED.t2_threshold,
|
||||
t3_threshold = EXCLUDED.t3_threshold,
|
||||
t4_threshold = EXCLUDED.t4_threshold
|
||||
RETURNING id
|
||||
""",
|
||||
(name, card_type, formula, t1, t2, t3, t4),
|
||||
)
|
||||
ids.append(cur.fetchone()[0])
|
||||
pg_conn.commit()
|
||||
yield ids
|
||||
cur.execute("DELETE FROM evolution_track WHERE id = ANY(%s)", (ids,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""FastAPI TestClient backed by the real PostgreSQL database."""
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_tracks_returns_count_3(client, seeded_tracks):
|
||||
"""GET /tracks returns all three tracks with count=3.
|
||||
|
||||
After seeding batter/sp/rp, the table should have exactly those three
|
||||
rows (no other tracks are inserted by other test modules).
|
||||
"""
|
||||
resp = client.get("/api/v2/evolution/tracks", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 3
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_filter_by_card_type(client, seeded_tracks):
|
||||
"""card_type=sp filter returns exactly 1 track with card_type 'sp'."""
|
||||
resp = client.get("/api/v2/evolution/tracks?card_type=sp", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 1
|
||||
assert data["items"][0]["card_type"] == "sp"
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_single_track_with_thresholds(client, seeded_tracks):
|
||||
"""GET /tracks/{id} returns a track dict with formula and t1-t4 thresholds."""
|
||||
track_id = seeded_tracks[0] # batter
|
||||
resp = client.get(f"/api/v2/evolution/tracks/{track_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["card_type"] == "batter"
|
||||
assert data["formula"] == "pa+tb*2"
|
||||
for key in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"):
|
||||
assert key in data, f"Missing field: {key}"
|
||||
assert data["t1_threshold"] == 37
|
||||
assert data["t4_threshold"] == 896
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_404_for_nonexistent_track(client, seeded_tracks):
|
||||
"""GET /tracks/999999 returns 404 when the track does not exist."""
|
||||
resp = client.get("/api/v2/evolution/tracks/999999", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_auth_required(client, seeded_tracks):
|
||||
"""Requests without a Bearer token return 401 for both endpoints."""
|
||||
resp_list = client.get("/api/v2/evolution/tracks")
|
||||
assert resp_list.status_code == 401
|
||||
|
||||
track_id = seeded_tracks[0]
|
||||
resp_single = client.get(f"/api/v2/evolution/tracks/{track_id}")
|
||||
assert resp_single.status_code == 401
|
||||
206
tests/test_formula_engine.py
Normal file
206
tests/test_formula_engine.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Tests for the formula engine (WP-09).
|
||||
|
||||
Unit tests only — no database required. Stats inputs are simple namespace
|
||||
objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose.
|
||||
|
||||
Tier thresholds used (from evolution_tracks.json seed data):
|
||||
Batter: t1=37, t2=149, t3=448, t4=896
|
||||
SP: t1=10, t2=40, t3=120, t4=240
|
||||
RP: t1=3, t2=12, t3=35, t4=70
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.formula_engine import (
|
||||
compute_batter_value,
|
||||
compute_rp_value,
|
||||
compute_sp_value,
|
||||
compute_value_for_track,
|
||||
tier_from_value,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def batter_stats(**kwargs):
|
||||
"""Build a minimal batter stats object with all fields defaulting to 0."""
|
||||
defaults = {"pa": 0, "hits": 0, "doubles": 0, "triples": 0, "hr": 0}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def pitcher_stats(**kwargs):
|
||||
"""Build a minimal pitcher stats object with all fields defaulting to 0."""
|
||||
defaults = {"outs": 0, "strikeouts": 0}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def track_dict(card_type: str) -> dict:
|
||||
"""Return the locked threshold dict for a given card_type."""
|
||||
return {
|
||||
"batter": {
|
||||
"card_type": "batter",
|
||||
"t1_threshold": 37,
|
||||
"t2_threshold": 149,
|
||||
"t3_threshold": 448,
|
||||
"t4_threshold": 896,
|
||||
},
|
||||
"sp": {
|
||||
"card_type": "sp",
|
||||
"t1_threshold": 10,
|
||||
"t2_threshold": 40,
|
||||
"t3_threshold": 120,
|
||||
"t4_threshold": 240,
|
||||
},
|
||||
"rp": {
|
||||
"card_type": "rp",
|
||||
"t1_threshold": 3,
|
||||
"t2_threshold": 12,
|
||||
"t3_threshold": 35,
|
||||
"t4_threshold": 70,
|
||||
},
|
||||
}[card_type]
|
||||
|
||||
|
||||
def track_ns(card_type: str):
|
||||
"""Return a namespace (attribute-style) track for a given card_type."""
|
||||
return SimpleNamespace(**track_dict(card_type))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_batter_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batter_formula_single_and_double():
|
||||
"""4 PA, 1 single, 1 double: PA=4, TB=1+2=3, value = 4 + 3×2 = 10."""
|
||||
stats = batter_stats(pa=4, hits=2, doubles=1)
|
||||
assert compute_batter_value(stats) == 10.0
|
||||
|
||||
|
||||
def test_batter_formula_no_hits():
|
||||
"""4 PA, 0 hits: TB=0, value = 4 + 0 = 4."""
|
||||
stats = batter_stats(pa=4)
|
||||
assert compute_batter_value(stats) == 4.0
|
||||
|
||||
|
||||
def test_batter_formula_hr_heavy():
|
||||
"""4 PA, 2 HR: TB = 0 singles + 4×2 = 8, value = 4 + 8×2 = 20."""
|
||||
stats = batter_stats(pa=4, hits=2, hr=2)
|
||||
assert compute_batter_value(stats) == 20.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_sp_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sp_formula_standard():
|
||||
"""18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0."""
|
||||
stats = pitcher_stats(outs=18, strikeouts=5)
|
||||
assert compute_sp_value(stats) == 11.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_rp_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rp_formula_standard():
|
||||
"""3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0."""
|
||||
stats = pitcher_stats(outs=3, strikeouts=2)
|
||||
assert compute_rp_value(stats) == 3.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zero stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batter_zero_stats_returns_zero():
|
||||
"""All-zero batter stats must return 0.0."""
|
||||
assert compute_batter_value(batter_stats()) == 0.0
|
||||
|
||||
|
||||
def test_sp_zero_stats_returns_zero():
|
||||
"""All-zero SP stats must return 0.0."""
|
||||
assert compute_sp_value(pitcher_stats()) == 0.0
|
||||
|
||||
|
||||
def test_rp_zero_stats_returns_zero():
|
||||
"""All-zero RP stats must return 0.0."""
|
||||
assert compute_rp_value(pitcher_stats()) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formula dispatch by track name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dispatch_batter():
|
||||
"""compute_value_for_track('batter', ...) delegates to compute_batter_value."""
|
||||
stats = batter_stats(pa=4, hits=2, doubles=1)
|
||||
assert compute_value_for_track("batter", stats) == compute_batter_value(stats)
|
||||
|
||||
|
||||
def test_dispatch_sp():
|
||||
"""compute_value_for_track('sp', ...) delegates to compute_sp_value."""
|
||||
stats = pitcher_stats(outs=18, strikeouts=5)
|
||||
assert compute_value_for_track("sp", stats) == compute_sp_value(stats)
|
||||
|
||||
|
||||
def test_dispatch_rp():
|
||||
"""compute_value_for_track('rp', ...) delegates to compute_rp_value."""
|
||||
stats = pitcher_stats(outs=3, strikeouts=2)
|
||||
assert compute_value_for_track("rp", stats) == compute_rp_value(stats)
|
||||
|
||||
|
||||
def test_dispatch_unknown_raises():
|
||||
"""An unrecognised card_type must raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Unknown card_type"):
|
||||
compute_value_for_track("dh", batter_stats())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tier_from_value — batter thresholds (t1=37, t2=149, t3=448, t4=896)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier_exact_t1_boundary():
|
||||
"""value=37 is exactly t1 for batter → T1."""
|
||||
assert tier_from_value(37, track_dict("batter")) == 1
|
||||
|
||||
|
||||
def test_tier_just_below_t1():
|
||||
"""value=36 is just below t1=37 for batter → T0."""
|
||||
assert tier_from_value(36, track_dict("batter")) == 0
|
||||
|
||||
|
||||
def test_tier_t4_boundary():
|
||||
"""value=896 is exactly t4 for batter → T4."""
|
||||
assert tier_from_value(896, track_dict("batter")) == 4
|
||||
|
||||
|
||||
def test_tier_above_t4():
|
||||
"""value above t4 still returns T4 (fully evolved)."""
|
||||
assert tier_from_value(1000, track_dict("batter")) == 4
|
||||
|
||||
|
||||
def test_tier_t2_boundary():
|
||||
"""value=149 is exactly t2 for batter → T2."""
|
||||
assert tier_from_value(149, track_dict("batter")) == 2
|
||||
|
||||
|
||||
def test_tier_t3_boundary():
|
||||
"""value=448 is exactly t3 for batter → T3."""
|
||||
assert tier_from_value(448, track_dict("batter")) == 3
|
||||
|
||||
|
||||
def test_tier_accepts_namespace_track():
|
||||
"""tier_from_value must work with attribute-style track objects (Peewee models)."""
|
||||
assert tier_from_value(37, track_ns("batter")) == 1
|
||||
667
tests/test_postgame_evolution.py
Normal file
667
tests/test_postgame_evolution.py
Normal file
@ -0,0 +1,667 @@
|
||||
"""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,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
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,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
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 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/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 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)
|
||||
|
||||
# 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/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
|
||||
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/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
|
||||
451
tests/test_season_stats_model.py
Normal file
451
tests/test_season_stats_model.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""Tests for BattingSeasonStats and PitchingSeasonStats Peewee models.
|
||||
|
||||
Unit tests verify model structure and defaults on unsaved instances without
|
||||
touching a database. Integration tests use an in-memory SQLite database to
|
||||
verify table creation, unique constraints, indexes, and the delta-update
|
||||
(increment) pattern.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from peewee import SqliteDatabase, IntegrityError
|
||||
|
||||
from app.models.season_stats import BattingSeasonStats, PitchingSeasonStats
|
||||
from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team, StratGame
|
||||
|
||||
# Dependency order matters for FK resolution.
|
||||
_TEST_MODELS = [
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
StratGame,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
]
|
||||
|
||||
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_db():
|
||||
"""Bind all models to an in-memory SQLite database, create tables, and
|
||||
tear them down after each test so each test starts from a clean state."""
|
||||
_test_db.bind(_TEST_MODELS)
|
||||
_test_db.create_tables(_TEST_MODELS)
|
||||
yield _test_db
|
||||
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
||||
|
||||
|
||||
# ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_rarity():
|
||||
return Rarity.create(value=1, name="Common", color="#ffffff")
|
||||
|
||||
|
||||
def make_cardset():
|
||||
return Cardset.create(name="2025", description="2025 Season", total_cards=100)
|
||||
|
||||
|
||||
def make_player(cardset, rarity, player_id=1):
|
||||
return Player.create(
|
||||
player_id=player_id,
|
||||
p_name="Test Player",
|
||||
cost=100,
|
||||
image="test.png",
|
||||
mlbclub="BOS",
|
||||
franchise="Boston",
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
rarity=rarity,
|
||||
pos_1="OF",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
|
||||
def make_team(abbrev="TEST", gmid=123456789):
|
||||
return Team.create(
|
||||
abbrev=abbrev,
|
||||
sname=abbrev,
|
||||
lname=f"Team {abbrev}",
|
||||
gmid=gmid,
|
||||
gmname="testuser",
|
||||
gsheet="https://example.com",
|
||||
wallet=1000,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=1,
|
||||
)
|
||||
|
||||
|
||||
def make_game(home_team, away_team, season=10):
|
||||
return StratGame.create(
|
||||
season=season,
|
||||
game_type="ranked",
|
||||
away_team=away_team,
|
||||
home_team=home_team,
|
||||
)
|
||||
|
||||
|
||||
def make_batting_stats(player, team, season=10, **kwargs):
|
||||
return BattingSeasonStats.create(player=player, team=team, season=season, **kwargs)
|
||||
|
||||
|
||||
def make_pitching_stats(player, team, season=10, **kwargs):
|
||||
return PitchingSeasonStats.create(player=player, team=team, season=season, **kwargs)
|
||||
|
||||
|
||||
# ── Shared column-list constants ─────────────────────────────────────────────
|
||||
|
||||
_BATTING_STAT_COLS = [
|
||||
"games",
|
||||
"pa",
|
||||
"ab",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"hr",
|
||||
"rbi",
|
||||
"runs",
|
||||
"bb",
|
||||
"strikeouts",
|
||||
"hbp",
|
||||
"sac",
|
||||
"ibb",
|
||||
"gidp",
|
||||
"sb",
|
||||
"cs",
|
||||
]
|
||||
|
||||
_PITCHING_STAT_COLS = [
|
||||
"games",
|
||||
"games_started",
|
||||
"outs",
|
||||
"strikeouts",
|
||||
"bb",
|
||||
"hits_allowed",
|
||||
"runs_allowed",
|
||||
"earned_runs",
|
||||
"hr_allowed",
|
||||
"hbp",
|
||||
"wild_pitches",
|
||||
"balks",
|
||||
"wins",
|
||||
"losses",
|
||||
"holds",
|
||||
"saves",
|
||||
"blown_saves",
|
||||
]
|
||||
|
||||
_KEY_COLS = ["player", "team", "season"]
|
||||
_META_COLS = ["last_game", "last_updated_at"]
|
||||
|
||||
|
||||
# ── Shared index helper ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_index_columns(db_conn, table: str) -> set:
|
||||
"""Return a set of frozensets, each being the column set of one index."""
|
||||
indexes = db_conn.execute_sql(f"PRAGMA index_list({table})").fetchall()
|
||||
result = set()
|
||||
for idx in indexes:
|
||||
idx_name = idx[1]
|
||||
cols = db_conn.execute_sql(f"PRAGMA index_info({idx_name})").fetchall()
|
||||
result.add(frozenset(col[2] for col in cols))
|
||||
return result
|
||||
|
||||
|
||||
# ── Unit: column completeness ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingColumnCompleteness:
|
||||
"""All required columns are present in BattingSeasonStats."""
|
||||
|
||||
EXPECTED_COLS = _BATTING_STAT_COLS
|
||||
KEY_COLS = _KEY_COLS
|
||||
META_COLS = _META_COLS
|
||||
|
||||
def test_stat_columns_present(self):
|
||||
"""All batting aggregate columns are present."""
|
||||
fields = BattingSeasonStats._meta.fields
|
||||
for col in self.EXPECTED_COLS:
|
||||
assert col in fields, f"Missing batting column: {col}"
|
||||
|
||||
def test_key_columns_present(self):
|
||||
"""player, team, and season columns are present."""
|
||||
fields = BattingSeasonStats._meta.fields
|
||||
for col in self.KEY_COLS:
|
||||
assert col in fields, f"Missing key column: {col}"
|
||||
|
||||
def test_meta_columns_present(self):
|
||||
"""Meta columns last_game and last_updated_at are present."""
|
||||
fields = BattingSeasonStats._meta.fields
|
||||
for col in self.META_COLS:
|
||||
assert col in fields, f"Missing meta column: {col}"
|
||||
|
||||
|
||||
class TestPitchingColumnCompleteness:
|
||||
"""All required columns are present in PitchingSeasonStats."""
|
||||
|
||||
EXPECTED_COLS = _PITCHING_STAT_COLS
|
||||
KEY_COLS = _KEY_COLS
|
||||
META_COLS = _META_COLS
|
||||
|
||||
def test_stat_columns_present(self):
|
||||
"""All pitching aggregate columns are present."""
|
||||
fields = PitchingSeasonStats._meta.fields
|
||||
for col in self.EXPECTED_COLS:
|
||||
assert col in fields, f"Missing pitching column: {col}"
|
||||
|
||||
def test_key_columns_present(self):
|
||||
"""player, team, and season columns are present."""
|
||||
fields = PitchingSeasonStats._meta.fields
|
||||
for col in self.KEY_COLS:
|
||||
assert col in fields, f"Missing key column: {col}"
|
||||
|
||||
def test_meta_columns_present(self):
|
||||
"""Meta columns last_game and last_updated_at are present."""
|
||||
fields = PitchingSeasonStats._meta.fields
|
||||
for col in self.META_COLS:
|
||||
assert col in fields, f"Missing meta column: {col}"
|
||||
|
||||
|
||||
# ── Unit: default values ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingDefaultValues:
|
||||
"""All integer stat columns default to 0; nullable meta fields default to None."""
|
||||
|
||||
INT_STAT_COLS = _BATTING_STAT_COLS
|
||||
|
||||
def test_all_int_columns_default_to_zero(self):
|
||||
"""Every integer stat column defaults to 0 on an unsaved instance."""
|
||||
row = BattingSeasonStats()
|
||||
for col in self.INT_STAT_COLS:
|
||||
val = getattr(row, col)
|
||||
assert val == 0, f"Column {col!r} default is {val!r}, expected 0"
|
||||
|
||||
def test_last_game_defaults_to_none(self):
|
||||
"""last_game FK is nullable and defaults to None."""
|
||||
row = BattingSeasonStats()
|
||||
assert row.last_game_id is None
|
||||
|
||||
def test_last_updated_at_defaults_to_none(self):
|
||||
"""last_updated_at defaults to None."""
|
||||
row = BattingSeasonStats()
|
||||
assert row.last_updated_at is None
|
||||
|
||||
|
||||
class TestPitchingDefaultValues:
|
||||
"""All integer stat columns default to 0; nullable meta fields default to None."""
|
||||
|
||||
INT_STAT_COLS = _PITCHING_STAT_COLS
|
||||
|
||||
def test_all_int_columns_default_to_zero(self):
|
||||
"""Every integer stat column defaults to 0 on an unsaved instance."""
|
||||
row = PitchingSeasonStats()
|
||||
for col in self.INT_STAT_COLS:
|
||||
val = getattr(row, col)
|
||||
assert val == 0, f"Column {col!r} default is {val!r}, expected 0"
|
||||
|
||||
def test_last_game_defaults_to_none(self):
|
||||
"""last_game FK is nullable and defaults to None."""
|
||||
row = PitchingSeasonStats()
|
||||
assert row.last_game_id is None
|
||||
|
||||
def test_last_updated_at_defaults_to_none(self):
|
||||
"""last_updated_at defaults to None."""
|
||||
row = PitchingSeasonStats()
|
||||
assert row.last_updated_at is None
|
||||
|
||||
|
||||
# ── Integration: unique constraint ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingUniqueConstraint:
|
||||
"""UNIQUE on (player_id, team_id, season) is enforced at the DB level."""
|
||||
|
||||
def test_duplicate_raises(self):
|
||||
"""Inserting a second row for the same (player, team, season) raises IntegrityError."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_batting_stats(player, team, season=10)
|
||||
with pytest.raises(IntegrityError):
|
||||
make_batting_stats(player, team, season=10)
|
||||
|
||||
def test_different_season_allowed(self):
|
||||
"""Same (player, team) in a different season creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_batting_stats(player, team, season=10)
|
||||
row2 = make_batting_stats(player, team, season=11)
|
||||
assert row2.id is not None
|
||||
|
||||
def test_different_team_allowed(self):
|
||||
"""Same (player, season) on a different team creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team1 = make_team("TM1", gmid=111)
|
||||
team2 = make_team("TM2", gmid=222)
|
||||
make_batting_stats(player, team1, season=10)
|
||||
row2 = make_batting_stats(player, team2, season=10)
|
||||
assert row2.id is not None
|
||||
|
||||
|
||||
class TestPitchingUniqueConstraint:
|
||||
"""UNIQUE on (player_id, team_id, season) is enforced at the DB level."""
|
||||
|
||||
def test_duplicate_raises(self):
|
||||
"""Inserting a second row for the same (player, team, season) raises IntegrityError."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_pitching_stats(player, team, season=10)
|
||||
with pytest.raises(IntegrityError):
|
||||
make_pitching_stats(player, team, season=10)
|
||||
|
||||
def test_different_season_allowed(self):
|
||||
"""Same (player, team) in a different season creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_pitching_stats(player, team, season=10)
|
||||
row2 = make_pitching_stats(player, team, season=11)
|
||||
assert row2.id is not None
|
||||
|
||||
|
||||
# ── Integration: delta update pattern ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingDeltaUpdate:
|
||||
"""Batting stats can be incremented (delta update) without replacing existing values."""
|
||||
|
||||
def test_increment_batting_stats(self):
|
||||
"""Updating pa and hits increments correctly."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_batting_stats(player, team, season=10, pa=5, hits=2)
|
||||
|
||||
BattingSeasonStats.update(
|
||||
pa=BattingSeasonStats.pa + 3,
|
||||
hits=BattingSeasonStats.hits + 1,
|
||||
).where(
|
||||
(BattingSeasonStats.player == player)
|
||||
& (BattingSeasonStats.team == team)
|
||||
& (BattingSeasonStats.season == 10)
|
||||
).execute()
|
||||
|
||||
updated = BattingSeasonStats.get_by_id(row.id)
|
||||
assert updated.pa == 8
|
||||
assert updated.hits == 3
|
||||
|
||||
def test_last_game_fk_is_nullable(self):
|
||||
"""last_game FK can be set to a StratGame instance or left NULL."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_batting_stats(player, team, season=10)
|
||||
assert row.last_game_id is None
|
||||
|
||||
game = make_game(home_team=team, away_team=team)
|
||||
BattingSeasonStats.update(last_game=game).where(
|
||||
BattingSeasonStats.id == row.id
|
||||
).execute()
|
||||
|
||||
updated = BattingSeasonStats.get_by_id(row.id)
|
||||
assert updated.last_game_id == game.id
|
||||
|
||||
|
||||
class TestPitchingDeltaUpdate:
|
||||
"""Pitching stats can be incremented (delta update) without replacing existing values."""
|
||||
|
||||
def test_increment_pitching_stats(self):
|
||||
"""Updating outs and strikeouts increments correctly."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_pitching_stats(player, team, season=10, outs=9, strikeouts=3)
|
||||
|
||||
PitchingSeasonStats.update(
|
||||
outs=PitchingSeasonStats.outs + 6,
|
||||
strikeouts=PitchingSeasonStats.strikeouts + 2,
|
||||
).where(
|
||||
(PitchingSeasonStats.player == player)
|
||||
& (PitchingSeasonStats.team == team)
|
||||
& (PitchingSeasonStats.season == 10)
|
||||
).execute()
|
||||
|
||||
updated = PitchingSeasonStats.get_by_id(row.id)
|
||||
assert updated.outs == 15
|
||||
assert updated.strikeouts == 5
|
||||
|
||||
def test_last_game_fk_is_nullable(self):
|
||||
"""last_game FK can be set to a StratGame instance or left NULL."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_pitching_stats(player, team, season=10)
|
||||
assert row.last_game_id is None
|
||||
|
||||
game = make_game(home_team=team, away_team=team)
|
||||
PitchingSeasonStats.update(last_game=game).where(
|
||||
PitchingSeasonStats.id == row.id
|
||||
).execute()
|
||||
|
||||
updated = PitchingSeasonStats.get_by_id(row.id)
|
||||
assert updated.last_game_id == game.id
|
||||
|
||||
|
||||
# ── Integration: index existence ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingIndexExistence:
|
||||
"""Required indexes exist on batting_season_stats."""
|
||||
|
||||
def test_unique_index_on_player_team_season(self, setup_test_db):
|
||||
"""A unique index covering (player_id, team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "batting_season_stats")
|
||||
assert frozenset({"player_id", "team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_team_season(self, setup_test_db):
|
||||
"""An index covering (team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "batting_season_stats")
|
||||
assert frozenset({"team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_player_season(self, setup_test_db):
|
||||
"""An index covering (player_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "batting_season_stats")
|
||||
assert frozenset({"player_id", "season"}) in index_sets
|
||||
|
||||
|
||||
class TestPitchingIndexExistence:
|
||||
"""Required indexes exist on pitching_season_stats."""
|
||||
|
||||
def test_unique_index_on_player_team_season(self, setup_test_db):
|
||||
"""A unique index covering (player_id, team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "pitching_season_stats")
|
||||
assert frozenset({"player_id", "team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_team_season(self, setup_test_db):
|
||||
"""An index covering (team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "pitching_season_stats")
|
||||
assert frozenset({"team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_player_season(self, setup_test_db):
|
||||
"""An index covering (player_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "pitching_season_stats")
|
||||
assert frozenset({"player_id", "season"}) in index_sets
|
||||
910
tests/test_season_stats_update.py
Normal file
910
tests/test_season_stats_update.py
Normal file
@ -0,0 +1,910 @@
|
||||
"""
|
||||
Tests for app/services/season_stats.py — update_season_stats().
|
||||
|
||||
What: Verify that the full-recalculation stat engine correctly aggregates
|
||||
StratPlay and Decision rows into BattingSeasonStats and PitchingSeasonStats,
|
||||
handles duplicate calls idempotently, accumulates stats across multiple games,
|
||||
and supports forced reprocessing for self-healing.
|
||||
|
||||
Why: This is the core bookkeeping engine for card evolution scoring. A
|
||||
double-count bug, a missed Decision merge, or a team-isolation failure
|
||||
would silently produce wrong stats that would then corrupt every
|
||||
evolution tier calculation downstream.
|
||||
|
||||
Test data is created using real Peewee models (no mocking) against the
|
||||
in-memory SQLite database provided by the autouse setup_test_db fixture
|
||||
in conftest.py. All Player and Team creation uses the actual required
|
||||
column set discovered from the model definition in db_engine.py.
|
||||
"""
|
||||
|
||||
import app.services.season_stats as _season_stats_module
|
||||
import pytest
|
||||
|
||||
from app.db_engine import (
|
||||
BattingSeasonStats,
|
||||
Cardset,
|
||||
Decision,
|
||||
PitchingSeasonStats,
|
||||
Player,
|
||||
Rarity,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Team,
|
||||
)
|
||||
from app.services.season_stats import update_season_stats
|
||||
from tests.conftest import _test_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level patch: redirect season_stats.db to the test database
|
||||
# ---------------------------------------------------------------------------
|
||||
# season_stats.py holds a module-level reference to the `db` object imported
|
||||
# from db_engine. When test models are rebound to _test_db via bind(), the
|
||||
# `db` object inside season_stats still points at the original production db
|
||||
# (SQLite file or PostgreSQL). We replace it here so that db.atomic() in
|
||||
# update_season_stats() operates on the same in-memory connection that the
|
||||
# test fixtures write to.
|
||||
_season_stats_module.db = _test_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cardset():
|
||||
"""Return a reusable Cardset row (or fetch the existing one by name)."""
|
||||
cs, _ = Cardset.get_or_create(
|
||||
name="Test Set",
|
||||
defaults={"description": "Test cardset", "total_cards": 100},
|
||||
)
|
||||
return cs
|
||||
|
||||
|
||||
def _make_rarity():
|
||||
"""Return the Common rarity singleton."""
|
||||
r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"})
|
||||
return r
|
||||
|
||||
|
||||
def _make_player(name: str, pos: str = "1B") -> Player:
|
||||
"""Create a Player row with all required (non-nullable) columns satisfied.
|
||||
|
||||
Why we need this helper: Player has many non-nullable varchar columns
|
||||
(image, mlbclub, franchise, description) and a required FK to Cardset.
|
||||
A single helper keeps test fixtures concise and consistent.
|
||||
"""
|
||||
return Player.create(
|
||||
p_name=name,
|
||||
rarity=_make_rarity(),
|
||||
cardset=_make_cardset(),
|
||||
set_num=1,
|
||||
pos_1=pos,
|
||||
image="https://example.com/image.png",
|
||||
mlbclub="TST",
|
||||
franchise="TST",
|
||||
description=f"Test player: {name}",
|
||||
)
|
||||
|
||||
|
||||
def _make_team(abbrev: str, gmid: int, season: int = 11) -> Team:
|
||||
"""Create a Team row with all required (non-nullable) columns satisfied."""
|
||||
return Team.create(
|
||||
abbrev=abbrev,
|
||||
sname=abbrev,
|
||||
lname=f"Team {abbrev}",
|
||||
gmid=gmid,
|
||||
gmname=f"gm_{abbrev.lower()}",
|
||||
gsheet="https://docs.google.com/spreadsheets/test",
|
||||
wallet=500,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=season,
|
||||
is_ai=False,
|
||||
)
|
||||
|
||||
|
||||
def make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats):
|
||||
"""Create a StratPlay row with sensible defaults for all required fields.
|
||||
|
||||
Why we provide defaults for every stat column: StratPlay has many
|
||||
IntegerField columns with default=0 at the model level, but supplying
|
||||
them explicitly makes it clear what the baseline state of each play is
|
||||
and keeps the helper signature stable if defaults change.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_a():
|
||||
return _make_team("TMA", gmid=1001)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_b():
|
||||
return _make_team("TMB", gmid=1002)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_batter():
|
||||
"""A batter-type player for team A."""
|
||||
return _make_player("Batter One", pos="CF")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_pitcher():
|
||||
"""A pitcher-type player for team B."""
|
||||
return _make_player("Pitcher One", pos="SP")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(team_a, team_b):
|
||||
return StratGame.create(
|
||||
season=11,
|
||||
game_type="ranked",
|
||||
away_team=team_a,
|
||||
home_team=team_b,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Existing behavior (kept)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""Batting stat totals from StratPlay rows are correctly accumulated.
|
||||
|
||||
What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a
|
||||
home run) for one batter. After update_season_stats(), the
|
||||
BattingSeasonStats row should reflect the exact sum of all play fields.
|
||||
|
||||
Why: The core of the batting aggregation pipeline. If any field mapping
|
||||
is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution
|
||||
scoring and leaderboards would silently report incorrect stats.
|
||||
"""
|
||||
# PA 1: single (hit=1, ab=1, pa=1)
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
)
|
||||
# PA 2: home run (hit=1, homerun=1, ab=1, pa=1, rbi=1, run=1)
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
homerun=1,
|
||||
rbi=1,
|
||||
run=1,
|
||||
outs=0,
|
||||
)
|
||||
# PA 3: strikeout (ab=1, pa=1, so=1, outs=1)
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
)
|
||||
# PA 4: walk (pa=1, bb=1)
|
||||
make_play(
|
||||
game,
|
||||
4,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
bb=1,
|
||||
outs=0,
|
||||
)
|
||||
|
||||
result = update_season_stats(game.id)
|
||||
|
||||
assert result["batters_updated"] >= 1
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 4
|
||||
assert stats.ab == 3
|
||||
assert stats.hits == 2
|
||||
assert stats.hr == 1
|
||||
assert stats.strikeouts == 1
|
||||
assert stats.bb == 1
|
||||
assert stats.rbi == 1
|
||||
assert stats.runs == 1
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_single_game_pitching_stats(
|
||||
team_a, team_b, player_batter, player_pitcher, game
|
||||
):
|
||||
"""Pitching stat totals (outs, k, hits_allowed, bb_allowed) are correct.
|
||||
|
||||
What: The same plays that create batting stats for the batter are also
|
||||
the source for the pitcher's opposing stats. This test checks that
|
||||
_recalc_pitching() correctly inverts batter-perspective fields.
|
||||
|
||||
Why: The batter's 'so' becomes the pitcher's 'strikeouts', the batter's
|
||||
'hit' becomes 'hits_allowed', etc. Any transposition in this mapping
|
||||
would corrupt pitcher stats silently.
|
||||
"""
|
||||
# Play 1: strikeout — batter so=1, outs=1
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
)
|
||||
# Play 2: single — batter hit=1
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
)
|
||||
# Play 3: walk — batter bb=1
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
bb=1,
|
||||
outs=0,
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
stats = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == player_pitcher,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
PitchingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.outs == 1 # one strikeout = one out recorded
|
||||
assert stats.strikeouts == 1 # batter's so → pitcher's strikeouts
|
||||
assert stats.hits_allowed == 1 # batter's hit → pitcher hits_allowed
|
||||
assert stats.bb == 1 # batter's bb → pitcher bb (walks allowed)
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""Decision.win=1 for a pitcher results in wins=1 in PitchingSeasonStats.
|
||||
|
||||
What: Add a single StratPlay to establish the pitcher in pitching pairs,
|
||||
then create a Decision row recording a win. Call update_season_stats()
|
||||
and verify the wins column is 1.
|
||||
|
||||
Why: Decisions are stored in a separate table from StratPlay. If
|
||||
_recalc_decisions() fails to merge them (wrong FK lookup, key mismatch),
|
||||
pitchers would always show 0 wins/losses/saves regardless of actual game
|
||||
outcomes, breaking standings and evolution criteria.
|
||||
"""
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
Decision.create(
|
||||
season=11,
|
||||
game=game,
|
||||
pitcher=player_pitcher,
|
||||
pitcher_team=team_b,
|
||||
win=1,
|
||||
loss=0,
|
||||
is_save=0,
|
||||
hold=0,
|
||||
b_save=0,
|
||||
is_start=True,
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
stats = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == player_pitcher,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
PitchingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.wins == 1
|
||||
assert stats.losses == 0
|
||||
|
||||
|
||||
def test_double_count_prevention(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""Calling update_season_stats() twice for the same game must not double the stats.
|
||||
|
||||
What: Process a game once (pa=3), then immediately call the function
|
||||
again with the same game_id. The second call finds the ProcessedGame
|
||||
ledger row and returns early with 'skipped'=True. The resulting pa
|
||||
should still be 3, not 6.
|
||||
|
||||
Why: The bot infrastructure may deliver game-complete events more than
|
||||
once (network retries, message replays). The ProcessedGame ledger
|
||||
provides full idempotency for all replay scenarios.
|
||||
"""
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
first_result = update_season_stats(game.id)
|
||||
assert "skipped" not in first_result
|
||||
|
||||
second_result = update_season_stats(game.id)
|
||||
assert second_result.get("skipped") is True
|
||||
assert second_result["batters_updated"] == 0
|
||||
assert second_result["pitchers_updated"] == 0
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
# Must still be 3, not 6
|
||||
assert stats.pa == 3
|
||||
|
||||
|
||||
def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher):
|
||||
"""Stats from two separate games are summed in a single BattingSeasonStats row.
|
||||
|
||||
What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team.
|
||||
After both updates the stats row should show pa=5.
|
||||
|
||||
Why: BattingSeasonStats is a season-long accumulator, not a per-game
|
||||
snapshot. The full recalculation queries all StratPlay rows for the season,
|
||||
so processing game 2 recomputes with all 5 PAs included.
|
||||
"""
|
||||
game1 = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
game2 = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
|
||||
# Game 1: 2 plate appearances
|
||||
for i in range(2):
|
||||
make_play(
|
||||
game1,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# Game 2: 3 plate appearances
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game2,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
update_season_stats(game1.id)
|
||||
update_season_stats(game2.id)
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 5
|
||||
assert stats.games == 2
|
||||
|
||||
|
||||
def test_two_team_game(team_a, team_b):
|
||||
"""Players from both teams in a game each get their own stats row.
|
||||
|
||||
What: Create a batter+pitcher pair for team A and another pair for team B.
|
||||
In the same game, team A bats against team B's pitcher and vice versa.
|
||||
After update_season_stats(), both batters and both pitchers must have
|
||||
correct, isolated stats rows.
|
||||
|
||||
Why: A key correctness guarantee is that stats are attributed to the
|
||||
correct (player, team) combination. If team attribution is wrong,
|
||||
a player's stats could appear under the wrong franchise or be merged
|
||||
with an opponent's row.
|
||||
"""
|
||||
batter_a = _make_player("Batter A", pos="CF")
|
||||
pitcher_a = _make_player("Pitcher A", pos="SP")
|
||||
batter_b = _make_player("Batter B", pos="CF")
|
||||
pitcher_b = _make_player("Pitcher B", pos="SP")
|
||||
|
||||
game = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
|
||||
# Team A bats against team B's pitcher (away half)
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
batter_a,
|
||||
team_a,
|
||||
pitcher_b,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
inning_half="top",
|
||||
)
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
batter_a,
|
||||
team_a,
|
||||
pitcher_b,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
inning_half="top",
|
||||
)
|
||||
|
||||
# Team B bats against team A's pitcher (home half)
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
batter_b,
|
||||
team_b,
|
||||
pitcher_a,
|
||||
team_a,
|
||||
pa=1,
|
||||
ab=1,
|
||||
bb=1,
|
||||
outs=0,
|
||||
inning_half="bottom",
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
# Team A's batter: 2 PA, 1 hit, 1 SO
|
||||
stats_ba = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == batter_a,
|
||||
BattingSeasonStats.team == team_a,
|
||||
)
|
||||
assert stats_ba.pa == 2
|
||||
assert stats_ba.hits == 1
|
||||
assert stats_ba.strikeouts == 1
|
||||
|
||||
# Team B's batter: 1 PA, 1 BB
|
||||
stats_bb = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == batter_b,
|
||||
BattingSeasonStats.team == team_b,
|
||||
)
|
||||
assert stats_bb.pa == 1
|
||||
assert stats_bb.bb == 1
|
||||
|
||||
# Team B's pitcher (faced team A's batter): 1 hit allowed, 1 strikeout
|
||||
stats_pb = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == pitcher_b,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
)
|
||||
assert stats_pb.hits_allowed == 1
|
||||
assert stats_pb.strikeouts == 1
|
||||
|
||||
# Team A's pitcher (faced team B's batter): 1 BB allowed
|
||||
stats_pa = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == pitcher_a,
|
||||
PitchingSeasonStats.team == team_a,
|
||||
)
|
||||
assert stats_pa.bb == 1
|
||||
|
||||
|
||||
def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher):
|
||||
"""Out-of-order processing and re-delivery produce correct stats.
|
||||
|
||||
What: Process game G+1 first (pa=2), then game G (pa=3). The full
|
||||
recalculation approach means both calls query all StratPlay rows for the
|
||||
season, so the final stats are always correct regardless of processing
|
||||
order. Re-delivering game G returns 'skipped'=True and leaves stats at 5.
|
||||
|
||||
Why: With full recalculation, out-of-order processing is inherently safe.
|
||||
The ProcessedGame ledger still prevents redundant work on re-delivery.
|
||||
"""
|
||||
game_g = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
game_g1 = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
|
||||
# Game G: 3 plate appearances
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game_g,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# Game G+1: 2 plate appearances
|
||||
for i in range(2):
|
||||
make_play(
|
||||
game_g1,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# Process G+1 first, then G — simulates out-of-order delivery
|
||||
update_season_stats(game_g1.id)
|
||||
update_season_stats(game_g.id)
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 5 # 3 (game G) + 2 (game G+1)
|
||||
|
||||
# Re-deliver game G — must be blocked by ProcessedGame ledger
|
||||
replay_result = update_season_stats(game_g.id)
|
||||
assert replay_result.get("skipped") is True
|
||||
|
||||
# Stats must remain at 5, not 8
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — New (force recalc / idempotency / self-healing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_force_recalc(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""Processing with force=True after initial processing does not double stats.
|
||||
|
||||
What: Process a game normally (pa=3), then reprocess with force=True.
|
||||
Because the recalculation reads all StratPlay rows and writes totals
|
||||
(not deltas), the stats remain at pa=3 after the forced reprocess.
|
||||
|
||||
Why: The force flag bypasses the ProcessedGame ledger skip, but since
|
||||
the underlying data hasn't changed, the recalculated totals must be
|
||||
identical. This proves the replacement upsert is safe.
|
||||
"""
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
)
|
||||
|
||||
first_result = update_season_stats(game.id)
|
||||
assert first_result["batters_updated"] >= 1
|
||||
assert "skipped" not in first_result
|
||||
|
||||
# Force reprocess — should NOT double stats
|
||||
force_result = update_season_stats(game.id, force=True)
|
||||
assert "skipped" not in force_result
|
||||
assert force_result["batters_updated"] >= 1
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 3
|
||||
assert stats.hits == 3
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_idempotent_reprocessing(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""Two consecutive force=True calls produce identical stats.
|
||||
|
||||
What: Force-process the same game twice. Both calls recompute from
|
||||
scratch, so the stats after the second call must be identical to the
|
||||
stats after the first call.
|
||||
|
||||
Why: Idempotency is a critical property of the recalculation engine.
|
||||
External systems (admin scripts, retry loops) may call force=True
|
||||
multiple times; the result must be stable.
|
||||
"""
|
||||
for i in range(4):
|
||||
make_play(
|
||||
game,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1 if i % 2 == 0 else 0,
|
||||
hit=0 if i % 2 == 0 else 1,
|
||||
outs=1 if i % 2 == 0 else 0,
|
||||
)
|
||||
|
||||
update_season_stats(game.id, force=True)
|
||||
stats_after_first = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
pa_1, hits_1, so_1 = (
|
||||
stats_after_first.pa,
|
||||
stats_after_first.hits,
|
||||
stats_after_first.strikeouts,
|
||||
)
|
||||
|
||||
update_season_stats(game.id, force=True)
|
||||
stats_after_second = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
|
||||
assert stats_after_second.pa == pa_1
|
||||
assert stats_after_second.hits == hits_1
|
||||
assert stats_after_second.strikeouts == so_1
|
||||
|
||||
|
||||
def test_partial_reprocessing_heals(
|
||||
team_a, team_b, player_batter, player_pitcher, game
|
||||
):
|
||||
"""Force reprocessing corrects manually corrupted stats.
|
||||
|
||||
What: Process a game (pa=3, hits=2), then manually corrupt the stats
|
||||
row (set pa=999). Force-reprocess the game. The stats should be healed
|
||||
back to the correct totals (pa=3, hits=2).
|
||||
|
||||
Why: This is the primary self-healing benefit of full recalculation.
|
||||
Partial processing, bugs, or manual edits can corrupt season stats;
|
||||
force=True recomputes from the source-of-truth StratPlay data and
|
||||
writes the correct totals regardless of current row state.
|
||||
"""
|
||||
# PA 1: single
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
)
|
||||
# PA 2: double
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
double=1,
|
||||
outs=0,
|
||||
)
|
||||
# PA 3: strikeout
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
# Verify correct initial state
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 3
|
||||
assert stats.hits == 2
|
||||
assert stats.doubles == 1
|
||||
|
||||
# Corrupt the stats manually
|
||||
stats.pa = 999
|
||||
stats.hits = 0
|
||||
stats.doubles = 50
|
||||
stats.save()
|
||||
|
||||
# Verify corruption took effect
|
||||
stats = BattingSeasonStats.get_by_id(stats.id)
|
||||
assert stats.pa == 999
|
||||
|
||||
# Force reprocess — should heal the corruption
|
||||
update_season_stats(game.id, force=True)
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 3
|
||||
assert stats.hits == 2
|
||||
assert stats.doubles == 1
|
||||
assert stats.strikeouts == 1
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_decision_only_pitcher(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""A pitcher with a Decision but no StratPlay rows still gets stats recorded.
|
||||
|
||||
What: Create a second pitcher who has a Decision (win) for the game but
|
||||
does not appear in any StratPlay rows. After update_season_stats(), the
|
||||
decision-only pitcher should have a PitchingSeasonStats row with wins=1
|
||||
and all play-level stats at 0.
|
||||
|
||||
Why: In rare cases a pitcher may be credited with a decision without
|
||||
recording any plays (e.g. inherited runner scoring rules, edge cases in
|
||||
game simulation). The old code handled this in _apply_decisions(); the
|
||||
new code must include Decision-scanned pitchers in _get_player_pairs().
|
||||
"""
|
||||
relief_pitcher = _make_player("Relief Pitcher", pos="RP")
|
||||
|
||||
# The main pitcher has plays
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# The relief pitcher has a Decision but NO StratPlay rows
|
||||
Decision.create(
|
||||
season=11,
|
||||
game=game,
|
||||
pitcher=relief_pitcher,
|
||||
pitcher_team=team_b,
|
||||
win=1,
|
||||
loss=0,
|
||||
is_save=0,
|
||||
hold=0,
|
||||
b_save=0,
|
||||
is_start=False,
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
# The relief pitcher should have a PitchingSeasonStats row
|
||||
stats = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == relief_pitcher,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
PitchingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.wins == 1
|
||||
assert stats.games == 0 # no plays, so COUNT(DISTINCT game) = 0
|
||||
assert stats.outs == 0
|
||||
assert stats.strikeouts == 0
|
||||
assert stats.games_started == 0
|
||||
Loading…
Reference in New Issue
Block a user