From 2c4ff01ff843e6c65fdc176eea3a29f528a1d560 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 3 Mar 2026 21:35:49 -0600 Subject: [PATCH 01/18] fix: batch Paperdex lookups to avoid N+1 queries (#17) Replace per-player/card Paperdex.select().where() calls with a single batched query grouped by player_id. Eliminates N+1 queries in: - players list endpoint (get_players, with inc_dex flag) - players by team endpoint - cards list endpoint (also materializes query to avoid double count()) Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/cards.py | 187 ++++++++++++++++++++++++-------------- app/routers_v2/players.py | 33 ++++--- 2 files changed, 138 insertions(+), 82 deletions(-) diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index 96b9774..7d3e0d0 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -7,11 +7,7 @@ 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 - -router = APIRouter( - prefix='/api/v2/cards', - tags=['cards'] -) +router = APIRouter(prefix="/api/v2/cards", tags=["cards"]) class CardPydantic(pydantic.BaseModel): @@ -26,12 +22,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 +69,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 +77,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(f"dupe check") p_query = Card.select(Card.player).where(Card.team_id == team_id) seen = set() dupes = [] @@ -90,38 +96,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,25 +150,31 @@ 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 @@ -157,7 +183,7 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): 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 +203,15 @@ 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') + raise HTTPException( + status_code=200, detail=f"{len(new_cards)} cards have been added" + ) # @router.post('/ai-update') @@ -198,21 +228,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,48 +258,51 @@ 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: @@ -273,19 +312,27 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)): 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 +365,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 +385,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") diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index dd842f8..1996463 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -295,16 +295,21 @@ async def get_players( else: return_val = {"count": len(final_players), "players": []} + dex_by_player = {} + if inc_dex: + player_ids = [p.player_id for p in final_players] + if player_ids: + for row in Paperdex.select().where(Paperdex.player_id << player_ids): + dex_by_player.setdefault(row.player_id, []).append(row) for x in final_players: this_record = model_to_dict(x, recurse=not (flat or short_output)) if inc_dex: - this_dex = Paperdex.select().where(Paperdex.player == x) - this_record["paperdex"] = {"count": this_dex.count(), "paperdex": []} - for y in this_dex: - this_record["paperdex"]["paperdex"].append( - model_to_dict(y, recurse=False) - ) + entries = dex_by_player.get(x.player_id, []) + this_record["paperdex"] = { + "count": len(entries), + "paperdex": [model_to_dict(y, recurse=False) for y in entries], + } if inc_keys and (flat or short_output): if this_record["mlbplayer"] is not None: @@ -473,15 +478,19 @@ async def get_random_player( else: return_val = {"count": len(final_players), "players": []} + player_ids = [p.player_id for p in final_players] + 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) for x in final_players: this_record = model_to_dict(x) - this_dex = Paperdex.select().where(Paperdex.player == x) - this_record["paperdex"] = {"count": this_dex.count(), "paperdex": []} - for y in this_dex: - this_record["paperdex"]["paperdex"].append( - model_to_dict(y, recurse=False) - ) + entries = dex_by_player.get(x.player_id, []) + this_record["paperdex"] = { + "count": len(entries), + "paperdex": [model_to_dict(y, recurse=False) for y in entries], + } return_val["players"].append(this_record) # return_val['players'].append(model_to_dict(x)) From c3732ef33e3d856d1e7a58dfb56a9cc7cca48d08 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Mar 2026 00:02:55 -0600 Subject: [PATCH 02/18] fix: remove stub live_update_pitching endpoint (#11) The /live-update/pitching POST endpoint was a placeholder that only validated auth and returned the input unchanged. No pitching processing logic existed anywhere in the codebase. Removed the dead endpoint. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/scouting.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/routers_v2/scouting.py b/app/routers_v2/scouting.py index 0e51f87..2c44bba 100644 --- a/app/routers_v2/scouting.py +++ b/app/routers_v2/scouting.py @@ -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() From 4f2513ae8b51ef815266f40c0caf14f1bd1b42bb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 7 Mar 2026 16:32:56 -0600 Subject: [PATCH 03/18] fix: use max() for pitcher OPS split weighting (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starters face both LHH and RHH, so the OPS aggregation formula should penalise the weaker platoon split (higher OPS allowed) rather than reward the stronger one. Changed min(ops_vl, ops_vr) → max(ops_vl, ops_vr) in both get_total_ops (line 621) and sort_starters (line 703) and replaced the TODO comment with an explanatory note. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/teams.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/routers_v2/teams.py b/app/routers_v2/teams.py index 91712a4..e4d4615 100644 --- a/app/routers_v2/teams.py +++ b/app/routers_v2/teams.py @@ -616,8 +616,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") @@ -698,7 +699,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") From f37217af2587a3e18a3caeeb7147bbb9edbf525c Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 10 Mar 2026 01:35:31 -0500 Subject: [PATCH 04/18] chore: pin all Python dependency versions in requirements.txt (#64) - Pin all 14 dependencies to exact versions (==) - Remove duplicate python-multipart entry - Upgrade numpy from floor constraint (<2) to exact pin (1.26.4, latest 1.x) - Pin Dockerfile base image from :latest to :python3.11 Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- requirements.txt | 29 ++++++++++++++--------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6b68f4a..63899b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM tiangolo/uvicorn-gunicorn-fastapi:latest +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11 WORKDIR /usr/src/app diff --git a/requirements.txt b/requirements.txt index bc854d6..f3dc46d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 From 4445acb7d064f9d38fbbfa110f31be6e5cdae277 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 10 Mar 2026 14:03:26 -0500 Subject: [PATCH 05/18] fix: materialize final_players queryset before double-iteration in get_random_player When no position filters are applied, `final_players` is a lazy Peewee queryset with `ORDER BY RANDOM() LIMIT n`. Iterating it twice (once to build player_ids, once for the response loop) executes two separate DB queries with different random seeds, causing dex_by_player to be built for a different player set than returned, silently producing empty paperdex for all players. Add `final_players = list(final_players)` before building player_ids to ensure both iterations operate on the same materialized result. Also fix pre-existing syntax error in import statement and minor ruff lint issues in the same file. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/players.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 1996463..aed9e53 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -12,7 +12,7 @@ from pandas import DataFrame from playwright.async_api import async_playwright from ..card_creation import get_batter_card_data, get_pitcher_card_data -from ..db_engine import (, DoesNotExist +from ..db_engine import ( db, Player, model_to_dict, @@ -74,7 +74,6 @@ def normalize_franchise(franchise: str) -> str: return FRANCHISE_NORMALIZE.get(titled, titled) - router = APIRouter(prefix="/api/v2/players", tags=["players"]) @@ -145,7 +144,7 @@ async def get_players( ): all_players = Player.select() if all_players.count() == 0: - raise HTTPException(status_code=404, detail=f"There are no players to filter") + raise HTTPException(status_code=404, detail="There are no players to filter") if name is not None: all_players = all_players.where(fn.Lower(Player.p_name) == name.lower()) @@ -477,6 +476,7 @@ async def get_random_player( return Response(content=return_val, media_type="text/csv") else: + final_players = list(final_players) return_val = {"count": len(final_players), "players": []} player_ids = [p.player_id for p in final_players] dex_by_player = {} @@ -684,9 +684,6 @@ async def get_batter_card( ) headers = {"Cache-Control": "public, max-age=86400"} - filename = ( - f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}" - ) if ( os.path.isfile( f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" From 4bfd878486aae88963e15388bd86266d59a5f336 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 16:35:02 -0500 Subject: [PATCH 06/18] feat: add PlayerSeasonStats Peewee model (#67) Closes #67 Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 132 +++++++++++- app/models/__init__.py | 0 app/models/evolution.py | 12 ++ app/models/season_stats.py | 7 + tests/__init__.py | 0 tests/conftest.py | 14 ++ tests/test_evolution_models.py | 338 +++++++++++++++++++++++++++++ tests/test_season_stats_model.py | 355 +++++++++++++++++++++++++++++++ 8 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 app/models/__init__.py create mode 100644 app/models/evolution.py create mode 100644 app/models/season_stats.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_evolution_models.py create mode 100644 tests/test_season_stats_model.py diff --git a/app/db_engine.py b/app/db_engine.py index 30e7d7c..0ec6bec 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1050,8 +1050,75 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique= Decision.add_index(decision_index) +class PlayerSeasonStats(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + season = IntegerField() + + # Batting stats + games_batting = IntegerField(default=0) + pa = IntegerField(default=0) + ab = IntegerField(default=0) + hits = IntegerField(default=0) + hr = IntegerField(default=0) + doubles = IntegerField(default=0) + triples = IntegerField(default=0) + bb = IntegerField(default=0) + hbp = IntegerField(default=0) + so = IntegerField(default=0) + rbi = IntegerField(default=0) + runs = IntegerField(default=0) + sb = IntegerField(default=0) + cs = IntegerField(default=0) + + # Pitching stats + games_pitching = IntegerField(default=0) + outs = IntegerField(default=0) + k = IntegerField( + default=0 + ) # pitcher Ks; spec names this "so (K)" but renamed to avoid collision with batting so + bb_allowed = IntegerField(default=0) + hits_allowed = IntegerField(default=0) + hr_allowed = IntegerField(default=0) + wins = IntegerField(default=0) + losses = IntegerField(default=0) + saves = IntegerField(default=0) + holds = IntegerField(default=0) + blown_saves = IntegerField(default=0) + + # Meta + last_game = ForeignKeyField(StratGame, null=True) + last_updated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "player_season_stats" + + +pss_unique_index = ModelIndex( + PlayerSeasonStats, + (PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season), + unique=True, +) +PlayerSeasonStats.add_index(pss_unique_index) + +pss_team_season_index = ModelIndex( + PlayerSeasonStats, + (PlayerSeasonStats.team, PlayerSeasonStats.season), + unique=False, +) +PlayerSeasonStats.add_index(pss_team_season_index) + +pss_player_season_index = ModelIndex( + PlayerSeasonStats, + (PlayerSeasonStats.player, PlayerSeasonStats.season), + unique=False, +) +PlayerSeasonStats.add_index(pss_player_season_index) + + if not SKIP_TABLE_CREATION: - db.create_tables([StratGame, StratPlay, Decision], safe=True) + db.create_tables([StratGame, StratPlay, Decision, PlayerSeasonStats], safe=True) class ScoutOpportunity(BaseModel): @@ -1089,6 +1156,69 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) +class EvolutionTrack(BaseModel): + name = CharField() + card_type = CharField() # batter / sp / rp + formula = CharField() + 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) # valid range: 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" + + +ecs_index = ModelIndex( + EvolutionCardState, + (EvolutionCardState.player, EvolutionCardState.team), + unique=True, +) +EvolutionCardState.add_index(ecs_index) + + +class EvolutionTierBoost(BaseModel): + """Phase 2 stub — minimal model, schema to be defined in phase 2.""" + + card_state = ForeignKeyField(EvolutionCardState) + + class Meta: + database = db + table_name = "evolution_tier_boost" + + +class EvolutionCosmetic(BaseModel): + """Phase 2 stub — minimal model, schema to be defined in phase 2.""" + + card_state = ForeignKeyField(EvolutionCardState) + + 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( diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/evolution.py b/app/models/evolution.py new file mode 100644 index 0000000..7763885 --- /dev/null +++ b/app/models/evolution.py @@ -0,0 +1,12 @@ +"""Evolution 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.evolution`. +""" + +from ..db_engine import ( # noqa: F401 + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +) diff --git a/app/models/season_stats.py b/app/models/season_stats.py new file mode 100644 index 0000000..bdd7ad1 --- /dev/null +++ b/app/models/season_stats.py @@ -0,0 +1,7 @@ +"""PlayerSeasonStats ORM model. + +Model is defined in db_engine alongside all other Peewee models; this +module re-exports it so callers can import from `app.models.season_stats`. +""" + +from ..db_engine import PlayerSeasonStats # noqa: F401 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8d61378 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +"""Pytest configuration for the paper-dynasty-database test suite. + +Sets DATABASE_TYPE=postgresql before any app module is imported so that +db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the +production SQLite file during test collection. Each test module is +responsible for binding models to its own in-memory database. +""" + +import os + +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") diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py new file mode 100644 index 0000000..1a6a4c9 --- /dev/null +++ b/tests/test_evolution_models.py @@ -0,0 +1,338 @@ +"""Tests for evolution Peewee models (WP-01). + +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, FK relationships, and unique constraints. +""" + +import pytest +from peewee import SqliteDatabase, IntegrityError +from playhouse.shortcuts import model_to_dict + +from app.models.evolution import ( + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +) +from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team + +# All models that must exist in the test database (dependency order). +_TEST_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +] + +_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): + return Player.create( + player_id=1, + 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(): + return Team.create( + abbrev="TEST", + sname="Test", + lname="Test Team", + gmid=123456789, + gmname="testuser", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + + +def make_track(card_type="batter"): + return EvolutionTrack.create( + name="Batter", + card_type=card_type, + formula="pa+tb*2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + + +# ── Unit: model field validation ─────────────────────────────────────────── + + +class TestEvolutionTrackFields: + """model_to_dict works on unsaved EvolutionTrack instances and all fields + are accessible with the correct values.""" + + def test_model_to_dict_unsaved(self): + """All EvolutionTrack fields appear in model_to_dict on an unsaved instance.""" + track = EvolutionTrack( + name="Batter", + card_type="batter", + formula="pa+tb*2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter" + 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_all_threshold_fields_present(self): + """EvolutionTrack exposes all four tier threshold columns.""" + fields = EvolutionTrack._meta.fields + for col in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"): + assert col in fields, f"Missing column: {col}" + + +class TestEvolutionCardStateFields: + """model_to_dict works on unsaved EvolutionCardState instances and + default values match the spec.""" + + def test_model_to_dict_defaults(self): + """Defaults: current_tier=0, current_value=0.0, fully_evolved=False, + last_evaluated_at=None.""" + state = EvolutionCardState() + data = model_to_dict(state, recurse=False) + assert data["current_tier"] == 0 + assert data["current_value"] == 0.0 + assert data["fully_evolved"] is False + assert data["last_evaluated_at"] is None + + def test_no_progress_since_field(self): + """EvolutionCardState must not have a progress_since field (removed from spec).""" + assert "progress_since" not in EvolutionCardState._meta.fields + + +class TestEvolutionStubFields: + """Phase 2 stub models are importable and respond to model_to_dict.""" + + def test_tier_boost_importable(self): + assert EvolutionTierBoost is not None + + def test_cosmetic_importable(self): + assert EvolutionCosmetic is not None + + def test_tier_boost_model_to_dict_unsaved(self): + """model_to_dict on an unsaved EvolutionTierBoost returns a dict.""" + data = model_to_dict(EvolutionTierBoost(), recurse=False) + assert isinstance(data, dict) + + def test_cosmetic_model_to_dict_unsaved(self): + """model_to_dict on an unsaved EvolutionCosmetic returns a dict.""" + data = model_to_dict(EvolutionCosmetic(), recurse=False) + assert isinstance(data, dict) + + +# ── Unit: constraint definitions ────────────────────────────────────────── + + +class TestTierConstraints: + """current_tier defaults to 0 and valid tier values (0-4) can be saved.""" + + def test_tier_zero_is_default(self): + """EvolutionCardState.current_tier defaults to 0 on create.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + assert state.current_tier == 0 + + def test_tier_four_is_valid(self): + """Tier 4 (fully evolved cap) can be persisted without error.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create( + player=player, team=team, track=track, current_tier=4 + ) + assert state.current_tier == 4 + + +class TestUniqueConstraint: + """Unique index on (player_id, team_id) is enforced at the DB level.""" + + def test_duplicate_player_team_raises(self): + """A second EvolutionCardState for the same (player, team) raises IntegrityError, + even when a different track is used.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track1 = make_track("batter") + track2 = EvolutionTrack.create( + name="SP", + card_type="sp", + formula="ip+k", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + EvolutionCardState.create(player=player, team=team, track=track1) + with pytest.raises(IntegrityError): + EvolutionCardState.create(player=player, team=team, track=track2) + + def test_same_player_different_teams_allowed(self): + """One EvolutionCardState per team is allowed for the same player.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team1 = make_team() + team2 = Team.create( + abbrev="TM2", + sname="T2", + lname="Team Two", + gmid=987654321, + gmname="user2", + gsheet="https://example.com", + wallet=1000, + team_value=1000, + collection_value=1000, + season=1, + ) + track = make_track() + EvolutionCardState.create(player=player, team=team1, track=track) + state2 = EvolutionCardState.create(player=player, team=team2, track=track) + assert state2.id is not None + + +# ── Integration: table creation ──────────────────────────────────────────── + + +class TestTableCreation: + """All four evolution tables are created in the test DB and are queryable.""" + + def test_evolution_track_table_exists(self): + assert EvolutionTrack.select().count() == 0 + + def test_evolution_card_state_table_exists(self): + assert EvolutionCardState.select().count() == 0 + + def test_evolution_tier_boost_table_exists(self): + assert EvolutionTierBoost.select().count() == 0 + + def test_evolution_cosmetic_table_exists(self): + assert EvolutionCosmetic.select().count() == 0 + + +# ── Integration: FK enforcement ──────────────────────────────────────────── + + +class TestFKEnforcement: + """FK columns resolve to the correct related instances.""" + + def test_card_state_player_fk_resolves(self): + """EvolutionCardState.player_id matches the Player we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.player_id == player.player_id + + def test_card_state_team_fk_resolves(self): + """EvolutionCardState.team_id matches the Team we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.team_id == team.id + + def test_card_state_track_fk_resolves(self): + """EvolutionCardState.track_id matches the EvolutionTrack we inserted.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.track_id == track.id + + +# ── Integration: model_to_dict on saved instances ────────────────────────── + + +class TestModelToDictOnSaved: + """model_to_dict() works correctly on saved instances of all four models.""" + + def test_evolution_track_saved(self): + """Saved EvolutionTrack round-trips through model_to_dict correctly.""" + track = make_track() + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter" + assert data["card_type"] == "batter" + assert data["formula"] == "pa+tb*2" + assert data["t1_threshold"] == 37 + + def test_evolution_card_state_saved(self): + """Saved EvolutionCardState round-trips through model_to_dict correctly.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + track = make_track() + state = EvolutionCardState.create( + player=player, team=team, track=track, current_value=42.5, current_tier=2 + ) + data = model_to_dict(state, recurse=False) + assert data["current_value"] == 42.5 + assert data["current_tier"] == 2 + assert data["fully_evolved"] is False diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py new file mode 100644 index 0000000..20fc3b8 --- /dev/null +++ b/tests/test_season_stats_model.py @@ -0,0 +1,355 @@ +"""Tests for PlayerSeasonStats Peewee model (WP-02). + +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. + +Note on column naming: the spec labels the pitching strikeout column as +"so (K)". This model names it `k` to avoid collision with the batting +strikeout column `so`. +""" + +import pytest +from peewee import SqliteDatabase, IntegrityError + +from app.models.season_stats import PlayerSeasonStats +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, + PlayerSeasonStats, +] + +_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_stats(player, team, season=10, **kwargs): + return PlayerSeasonStats.create(player=player, team=team, season=season, **kwargs) + + +# ── Unit: column completeness ──────────────────────────────────────────────── + + +class TestColumnCompleteness: + """All required columns are present in the model's field definitions.""" + + BATTING_COLS = [ + "games_batting", + "pa", + "ab", + "hits", + "hr", + "doubles", + "triples", + "bb", + "hbp", + "so", + "rbi", + "runs", + "sb", + "cs", + ] + PITCHING_COLS = [ + "games_pitching", + "outs", + "k", + "bb_allowed", + "hits_allowed", + "hr_allowed", + "wins", + "losses", + "saves", + "holds", + "blown_saves", + ] + META_COLS = ["last_game", "last_updated_at"] + KEY_COLS = ["player", "team", "season"] + + def test_batting_columns_present(self): + """All batting aggregate columns defined in the spec are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.BATTING_COLS: + assert col in fields, f"Missing batting column: {col}" + + def test_pitching_columns_present(self): + """All pitching aggregate columns defined in the spec are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.PITCHING_COLS: + assert col in fields, f"Missing pitching column: {col}" + + def test_meta_columns_present(self): + """Meta columns last_game and last_updated_at are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.META_COLS: + assert col in fields, f"Missing meta column: {col}" + + def test_key_columns_present(self): + """player, team, and season columns are present.""" + fields = PlayerSeasonStats._meta.fields + for col in self.KEY_COLS: + assert col in fields, f"Missing key column: {col}" + + def test_excluded_columns_absent(self): + """team_wins and quality_starts are NOT in the model (removed from scope).""" + fields = PlayerSeasonStats._meta.fields + assert "team_wins" not in fields + assert "quality_starts" not in fields + + +# ── Unit: default values ───────────────────────────────────────────────────── + + +class TestDefaultValues: + """All integer stat columns default to 0; nullable meta fields default to None.""" + + INT_STAT_COLS = [ + "games_batting", + "pa", + "ab", + "hits", + "hr", + "doubles", + "triples", + "bb", + "hbp", + "so", + "rbi", + "runs", + "sb", + "cs", + "games_pitching", + "outs", + "k", + "bb_allowed", + "hits_allowed", + "hr_allowed", + "wins", + "losses", + "saves", + "holds", + "blown_saves", + ] + + def test_all_int_columns_default_to_zero(self): + """Every integer stat column defaults to 0 on an unsaved instance.""" + row = PlayerSeasonStats() + 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 = PlayerSeasonStats() + assert row.last_game_id is None + + def test_last_updated_at_defaults_to_none(self): + """last_updated_at defaults to None.""" + row = PlayerSeasonStats() + assert row.last_updated_at is None + + +# ── Integration: unique constraint ─────────────────────────────────────────── + + +class TestUniqueConstraint: + """UNIQUE on (player_id, team_id, season) is enforced at the DB level.""" + + def test_duplicate_player_team_season_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_stats(player, team, season=10) + with pytest.raises(IntegrityError): + make_stats(player, team, season=10) + + def test_same_player_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_stats(player, team, season=10) + row2 = make_stats(player, team, season=11) + assert row2.id is not None + + def test_same_player_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_stats(player, team1, season=10) + row2 = make_stats(player, team2, season=10) + assert row2.id is not None + + +# ── Integration: delta update pattern ─────────────────────────────────────── + + +class TestDeltaUpdatePattern: + """Stats can be incremented (delta update) without replacing existing values.""" + + def test_increment_batting_stats(self): + """Updating pa and hits increments without touching pitching columns.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + row = make_stats(player, team, season=10, pa=5, hits=2) + + PlayerSeasonStats.update( + pa=PlayerSeasonStats.pa + 3, + hits=PlayerSeasonStats.hits + 1, + ).where( + (PlayerSeasonStats.player == player) + & (PlayerSeasonStats.team == team) + & (PlayerSeasonStats.season == 10) + ).execute() + + updated = PlayerSeasonStats.get_by_id(row.id) + assert updated.pa == 8 + assert updated.hits == 3 + assert updated.games_pitching == 0 # untouched + + def test_increment_pitching_stats(self): + """Updating outs and k increments without touching batting columns.""" + rarity = make_rarity() + cardset = make_cardset() + player = make_player(cardset, rarity) + team = make_team() + row = make_stats(player, team, season=10, outs=9, k=3) + + PlayerSeasonStats.update( + outs=PlayerSeasonStats.outs + 6, + k=PlayerSeasonStats.k + 2, + ).where( + (PlayerSeasonStats.player == player) + & (PlayerSeasonStats.team == team) + & (PlayerSeasonStats.season == 10) + ).execute() + + updated = PlayerSeasonStats.get_by_id(row.id) + assert updated.outs == 15 + assert updated.k == 5 + assert updated.pa == 0 # untouched + + 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_stats(player, team, season=10) + assert row.last_game_id is None + + game = make_game(home_team=team, away_team=team) + PlayerSeasonStats.update(last_game=game).where( + PlayerSeasonStats.id == row.id + ).execute() + + updated = PlayerSeasonStats.get_by_id(row.id) + assert updated.last_game_id == game.id + + +# ── Integration: index existence ───────────────────────────────────────────── + + +class TestIndexExistence: + """Required indexes on (team_id, season) and (player_id, season) exist in SQLite.""" + + def _get_index_columns(self, db, table): + """Return a set of frozensets, each being the column set of one index.""" + indexes = db.execute_sql(f"PRAGMA index_list({table})").fetchall() + result = set() + for idx in indexes: + idx_name = idx[1] + cols = db.execute_sql(f"PRAGMA index_info({idx_name})").fetchall() + result.add(frozenset(col[2] for col in cols)) + return result + + def test_unique_index_on_player_team_season(self, setup_test_db): + """A unique index covering (player_id, team_id, season) exists.""" + index_sets = self._get_index_columns(setup_test_db, "player_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 = self._get_index_columns(setup_test_db, "player_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 = self._get_index_columns(setup_test_db, "player_season_stats") + assert frozenset({"player_id", "season"}) in index_sets From 8dfc5ef3716cf3cbb86557f4c00402df81ada70c Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 17:02:00 -0500 Subject: [PATCH 07/18] fix: remove evolution models from WP-02 PR (#82) Evolution models (EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic), their re-export module, and tests were included in this PR without disclosure. Removed to keep this PR scoped to PlayerSeasonStats (WP-02) only per review feedback. Co-Authored-By: Claude Sonnet 4.6 --- app/db_engine.py | 63 ------ app/models/evolution.py | 12 -- tests/test_evolution_models.py | 338 --------------------------------- 3 files changed, 413 deletions(-) delete mode 100644 app/models/evolution.py delete mode 100644 tests/test_evolution_models.py diff --git a/app/db_engine.py b/app/db_engine.py index 0ec6bec..bb9c9f0 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1156,69 +1156,6 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) -class EvolutionTrack(BaseModel): - name = CharField() - card_type = CharField() # batter / sp / rp - formula = CharField() - 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) # valid range: 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" - - -ecs_index = ModelIndex( - EvolutionCardState, - (EvolutionCardState.player, EvolutionCardState.team), - unique=True, -) -EvolutionCardState.add_index(ecs_index) - - -class EvolutionTierBoost(BaseModel): - """Phase 2 stub — minimal model, schema to be defined in phase 2.""" - - card_state = ForeignKeyField(EvolutionCardState) - - class Meta: - database = db - table_name = "evolution_tier_boost" - - -class EvolutionCosmetic(BaseModel): - """Phase 2 stub — minimal model, schema to be defined in phase 2.""" - - card_state = ForeignKeyField(EvolutionCardState) - - 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( diff --git a/app/models/evolution.py b/app/models/evolution.py deleted file mode 100644 index 7763885..0000000 --- a/app/models/evolution.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Evolution 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.evolution`. -""" - -from ..db_engine import ( # noqa: F401 - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, -) diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py deleted file mode 100644 index 1a6a4c9..0000000 --- a/tests/test_evolution_models.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Tests for evolution Peewee models (WP-01). - -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, FK relationships, and unique constraints. -""" - -import pytest -from peewee import SqliteDatabase, IntegrityError -from playhouse.shortcuts import model_to_dict - -from app.models.evolution import ( - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, -) -from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team - -# All models that must exist in the test database (dependency order). -_TEST_MODELS = [ - Rarity, - Event, - Cardset, - MlbPlayer, - Player, - Team, - EvolutionTrack, - EvolutionCardState, - EvolutionTierBoost, - EvolutionCosmetic, -] - -_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): - return Player.create( - player_id=1, - 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(): - return Team.create( - abbrev="TEST", - sname="Test", - lname="Test Team", - gmid=123456789, - gmname="testuser", - gsheet="https://example.com", - wallet=1000, - team_value=1000, - collection_value=1000, - season=1, - ) - - -def make_track(card_type="batter"): - return EvolutionTrack.create( - name="Batter", - card_type=card_type, - formula="pa+tb*2", - t1_threshold=37, - t2_threshold=149, - t3_threshold=448, - t4_threshold=896, - ) - - -# ── Unit: model field validation ─────────────────────────────────────────── - - -class TestEvolutionTrackFields: - """model_to_dict works on unsaved EvolutionTrack instances and all fields - are accessible with the correct values.""" - - def test_model_to_dict_unsaved(self): - """All EvolutionTrack fields appear in model_to_dict on an unsaved instance.""" - track = EvolutionTrack( - name="Batter", - card_type="batter", - formula="pa+tb*2", - t1_threshold=37, - t2_threshold=149, - t3_threshold=448, - t4_threshold=896, - ) - data = model_to_dict(track, recurse=False) - assert data["name"] == "Batter" - 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_all_threshold_fields_present(self): - """EvolutionTrack exposes all four tier threshold columns.""" - fields = EvolutionTrack._meta.fields - for col in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"): - assert col in fields, f"Missing column: {col}" - - -class TestEvolutionCardStateFields: - """model_to_dict works on unsaved EvolutionCardState instances and - default values match the spec.""" - - def test_model_to_dict_defaults(self): - """Defaults: current_tier=0, current_value=0.0, fully_evolved=False, - last_evaluated_at=None.""" - state = EvolutionCardState() - data = model_to_dict(state, recurse=False) - assert data["current_tier"] == 0 - assert data["current_value"] == 0.0 - assert data["fully_evolved"] is False - assert data["last_evaluated_at"] is None - - def test_no_progress_since_field(self): - """EvolutionCardState must not have a progress_since field (removed from spec).""" - assert "progress_since" not in EvolutionCardState._meta.fields - - -class TestEvolutionStubFields: - """Phase 2 stub models are importable and respond to model_to_dict.""" - - def test_tier_boost_importable(self): - assert EvolutionTierBoost is not None - - def test_cosmetic_importable(self): - assert EvolutionCosmetic is not None - - def test_tier_boost_model_to_dict_unsaved(self): - """model_to_dict on an unsaved EvolutionTierBoost returns a dict.""" - data = model_to_dict(EvolutionTierBoost(), recurse=False) - assert isinstance(data, dict) - - def test_cosmetic_model_to_dict_unsaved(self): - """model_to_dict on an unsaved EvolutionCosmetic returns a dict.""" - data = model_to_dict(EvolutionCosmetic(), recurse=False) - assert isinstance(data, dict) - - -# ── Unit: constraint definitions ────────────────────────────────────────── - - -class TestTierConstraints: - """current_tier defaults to 0 and valid tier values (0-4) can be saved.""" - - def test_tier_zero_is_default(self): - """EvolutionCardState.current_tier defaults to 0 on create.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - assert state.current_tier == 0 - - def test_tier_four_is_valid(self): - """Tier 4 (fully evolved cap) can be persisted without error.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create( - player=player, team=team, track=track, current_tier=4 - ) - assert state.current_tier == 4 - - -class TestUniqueConstraint: - """Unique index on (player_id, team_id) is enforced at the DB level.""" - - def test_duplicate_player_team_raises(self): - """A second EvolutionCardState for the same (player, team) raises IntegrityError, - even when a different track is used.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track1 = make_track("batter") - track2 = EvolutionTrack.create( - name="SP", - card_type="sp", - formula="ip+k", - t1_threshold=10, - t2_threshold=40, - t3_threshold=120, - t4_threshold=240, - ) - EvolutionCardState.create(player=player, team=team, track=track1) - with pytest.raises(IntegrityError): - EvolutionCardState.create(player=player, team=team, track=track2) - - def test_same_player_different_teams_allowed(self): - """One EvolutionCardState per team is allowed for the same player.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team1 = make_team() - team2 = Team.create( - abbrev="TM2", - sname="T2", - lname="Team Two", - gmid=987654321, - gmname="user2", - gsheet="https://example.com", - wallet=1000, - team_value=1000, - collection_value=1000, - season=1, - ) - track = make_track() - EvolutionCardState.create(player=player, team=team1, track=track) - state2 = EvolutionCardState.create(player=player, team=team2, track=track) - assert state2.id is not None - - -# ── Integration: table creation ──────────────────────────────────────────── - - -class TestTableCreation: - """All four evolution tables are created in the test DB and are queryable.""" - - def test_evolution_track_table_exists(self): - assert EvolutionTrack.select().count() == 0 - - def test_evolution_card_state_table_exists(self): - assert EvolutionCardState.select().count() == 0 - - def test_evolution_tier_boost_table_exists(self): - assert EvolutionTierBoost.select().count() == 0 - - def test_evolution_cosmetic_table_exists(self): - assert EvolutionCosmetic.select().count() == 0 - - -# ── Integration: FK enforcement ──────────────────────────────────────────── - - -class TestFKEnforcement: - """FK columns resolve to the correct related instances.""" - - def test_card_state_player_fk_resolves(self): - """EvolutionCardState.player_id matches the Player we inserted.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) - assert fetched.player_id == player.player_id - - def test_card_state_team_fk_resolves(self): - """EvolutionCardState.team_id matches the Team we inserted.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) - assert fetched.team_id == team.id - - def test_card_state_track_fk_resolves(self): - """EvolutionCardState.track_id matches the EvolutionTrack we inserted.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create(player=player, team=team, track=track) - fetched = EvolutionCardState.get_by_id(state.id) - assert fetched.track_id == track.id - - -# ── Integration: model_to_dict on saved instances ────────────────────────── - - -class TestModelToDictOnSaved: - """model_to_dict() works correctly on saved instances of all four models.""" - - def test_evolution_track_saved(self): - """Saved EvolutionTrack round-trips through model_to_dict correctly.""" - track = make_track() - data = model_to_dict(track, recurse=False) - assert data["name"] == "Batter" - assert data["card_type"] == "batter" - assert data["formula"] == "pa+tb*2" - assert data["t1_threshold"] == 37 - - def test_evolution_card_state_saved(self): - """Saved EvolutionCardState round-trips through model_to_dict correctly.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - track = make_track() - state = EvolutionCardState.create( - player=player, team=team, track=track, current_value=42.5, current_tier=2 - ) - data = model_to_dict(state, recurse=False) - assert data["current_value"] == 42.5 - assert data["current_tier"] == 2 - assert data["fully_evolved"] is False From 25f04892c2205389094eea74b1b3a59e8202759d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 17:35:12 -0500 Subject: [PATCH 08/18] feat: evolution track seed data and tests (WP-03) (#68) Closes #68 Co-Authored-By: Claude Sonnet 4.6 --- app/seed/__init__.py | 0 app/seed/evolution_tracks.json | 5 ++ app/seed/evolution_tracks.py | 41 ++++++++++++ tests/__init__.py | 0 tests/test_evolution_seed.py | 119 +++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 app/seed/__init__.py create mode 100644 app/seed/evolution_tracks.json create mode 100644 app/seed/evolution_tracks.py create mode 100644 tests/__init__.py create mode 100644 tests/test_evolution_seed.py diff --git a/app/seed/__init__.py b/app/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/seed/evolution_tracks.json b/app/seed/evolution_tracks.json new file mode 100644 index 0000000..a4bd1f0 --- /dev/null +++ b/app/seed/evolution_tracks.json @@ -0,0 +1,5 @@ +[ + {"name": "Batter", "card_type": "batter", "formula": "pa+tb*2", "t1": 37, "t2": 149, "t3": 448, "t4": 896}, + {"name": "Starting Pitcher", "card_type": "sp", "formula": "ip+k", "t1": 10, "t2": 40, "t3": 120, "t4": 240}, + {"name": "Relief Pitcher", "card_type": "rp", "formula": "ip+k", "t1": 3, "t2": 12, "t3": 35, "t4": 70} +] diff --git a/app/seed/evolution_tracks.py b/app/seed/evolution_tracks.py new file mode 100644 index 0000000..178f68e --- /dev/null +++ b/app/seed/evolution_tracks.py @@ -0,0 +1,41 @@ +"""Seed data fixture for EvolutionTrack. + +Inserts the three universal evolution tracks (Batter, Starting Pitcher, +Relief Pitcher) if they do not already exist. Safe to call multiple times +thanks to get_or_create — depends on WP-01 (EvolutionTrack model) to run. +""" + +import json +import os + +_JSON_PATH = os.path.join(os.path.dirname(__file__), "evolution_tracks.json") + + +def load_tracks(): + """Return the locked list of evolution track dicts from the JSON fixture.""" + with open(_JSON_PATH) as fh: + return json.load(fh) + + +def seed(model_class=None): + """Insert evolution tracks that are not yet in the database. + + Args: + model_class: Peewee model with get_or_create support. Defaults to + ``app.db_engine.EvolutionTrack`` (imported lazily so this module + can be imported before WP-01 lands). + + Returns: + List of (instance, created) tuples from get_or_create. + """ + if model_class is None: + from app.db_engine import EvolutionTrack as model_class # noqa: PLC0415 + + results = [] + for track in load_tracks(): + instance, created = model_class.get_or_create( + card_type=track["card_type"], + defaults=track, + ) + results.append((instance, created)) + return results diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_evolution_seed.py b/tests/test_evolution_seed.py new file mode 100644 index 0000000..8aed49c --- /dev/null +++ b/tests/test_evolution_seed.py @@ -0,0 +1,119 @@ +"""Tests for the evolution track seed data fixture (WP-03). + +Unit tests verify the JSON fixture is correctly formed without touching any +database. The integration test binds a minimal in-memory EvolutionTrack +model (mirroring the schema WP-01 will add to db_engine) to an in-memory +SQLite database, calls seed(), and verifies idempotency. +""" + +import pytest +from peewee import CharField, IntegerField, Model, SqliteDatabase + +from app.seed.evolution_tracks import load_tracks, seed + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_test_db = SqliteDatabase(":memory:") + + +class EvolutionTrackStub(Model): + """Minimal EvolutionTrack model for integration tests. + + Mirrors the schema that WP-01 will add to db_engine so the integration + test can run without WP-01 being merged. + """ + + name = CharField() + card_type = CharField(unique=True) + formula = CharField() + t1 = IntegerField() + t2 = IntegerField() + t3 = IntegerField() + t4 = IntegerField() + + class Meta: + database = _test_db + table_name = "evolution_track" + + +@pytest.fixture(autouse=True) +def _db(): + """Bind and create the stub table; drop it after each test.""" + _test_db.connect(reuse_if_open=True) + _test_db.create_tables([EvolutionTrackStub]) + yield + _test_db.drop_tables([EvolutionTrackStub]) + + +# --------------------------------------------------------------------------- +# Unit tests — JSON fixture only, no database +# --------------------------------------------------------------------------- + + +def test_three_tracks_in_seed_data(): + """load_tracks() must return exactly 3 evolution tracks.""" + assert len(load_tracks()) == 3 + + +def test_card_types_are_exactly_batter_sp_rp(): + """The set of card_type values must be exactly {'batter', 'sp', 'rp'}.""" + types = {t["card_type"] for t in load_tracks()} + assert types == {"batter", "sp", "rp"} + + +def test_all_thresholds_positive_and_ascending(): + """Each track must have t1 < t2 < t3 < t4, all positive.""" + for track in load_tracks(): + assert track["t1"] > 0 + assert track["t1"] < track["t2"] < track["t3"] < track["t4"] + + +def test_all_tracks_have_non_empty_formula(): + """Every track must have a non-empty formula string.""" + for track in load_tracks(): + assert isinstance(track["formula"], str) and track["formula"].strip() + + +def test_tier_thresholds_match_locked_values(): + """Threshold values must exactly match the locked design spec.""" + tracks = {t["card_type"]: t for t in load_tracks()} + + assert tracks["batter"]["t1"] == 37 + assert tracks["batter"]["t2"] == 149 + assert tracks["batter"]["t3"] == 448 + assert tracks["batter"]["t4"] == 896 + + assert tracks["sp"]["t1"] == 10 + assert tracks["sp"]["t2"] == 40 + assert tracks["sp"]["t3"] == 120 + assert tracks["sp"]["t4"] == 240 + + assert tracks["rp"]["t1"] == 3 + assert tracks["rp"]["t2"] == 12 + assert tracks["rp"]["t3"] == 35 + assert tracks["rp"]["t4"] == 70 + + +# --------------------------------------------------------------------------- +# Integration test — uses the stub model + in-memory SQLite +# --------------------------------------------------------------------------- + + +def test_seed_is_idempotent(): + """Calling seed() twice must not create duplicate rows (get_or_create). + + First call: all three tracks created (created=True for each). + Second call: all three already exist (created=False for each). + Both calls succeed without error. + """ + results_first = seed(model_class=EvolutionTrackStub) + assert len(results_first) == 3 + assert all(created for _, created in results_first) + + results_second = seed(model_class=EvolutionTrackStub) + assert len(results_second) == 3 + assert not any(created for _, created in results_second) + + assert EvolutionTrackStub.select().count() == 3 From 40e988ac9d79f03a61df39146526f62da5116d43 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 19:34:40 -0500 Subject: [PATCH 09/18] feat: formula engine for evolution value computation (WP-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #74 Adds app/services/formula_engine.py with three pure formula functions (compute_batter_value, compute_sp_value, compute_rp_value), a dispatch helper (compute_value_for_track), and a tier classifier (tier_from_value). Tier boundaries and thresholds match the locked seed data from WP-03. Note: pitcher formulas use stats.k (not stats.so) to match the PlayerSeasonStats model field name introduced in WP-02. 19 unit tests in tests/test_formula_engine.py — all pass. Co-Authored-By: Claude Sonnet 4.6 --- app/services/__init__.py | 0 app/services/formula_engine.py | 105 ++++++++++++++++++ tests/test_formula_engine.py | 188 +++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 app/services/__init__.py create mode 100644 app/services/formula_engine.py create mode 100644 tests/test_formula_engine.py diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py new file mode 100644 index 0000000..6178363 --- /dev/null +++ b/app/services/formula_engine.py @@ -0,0 +1,105 @@ +"""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 + compute_sp_value: outs, k (k = pitcher strikeouts, from PlayerSeasonStats) + compute_rp_value: outs, k +""" + +from typing import Protocol + + +class BatterStats(Protocol): + pa: int + hits: int + doubles: int + triples: int + hr: int + + +class PitcherStats(Protocol): + outs: int + k: int + + +# --------------------------------------------------------------------------- +# Core formula functions +# --------------------------------------------------------------------------- + + +def compute_batter_value(stats) -> float: + """PA + (TB × 2) where TB = 1B + 2×2B + 3×3B + 4×HR.""" + 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 compute_sp_value(stats) -> float: + """IP + K where IP = outs / 3. Uses stats.k (pitcher strikeouts).""" + return stats.outs / 3 + stats.k + + +def compute_rp_value(stats) -> float: + """IP + K (same formula as SP; thresholds differ). Uses stats.k.""" + return stats.outs / 3 + stats.k + + +# --------------------------------------------------------------------------- +# 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, t2, t3, t4 attributes/keys. + """ + # Support both attribute-style (Peewee model) and dict (seed fixture) + if isinstance(track, dict): + t1, t2, t3, t4 = track["t1"], track["t2"], track["t3"], track["t4"] + else: + t1, t2, t3, t4 = track.t1, track.t2, track.t3, track.t4 + + if value >= t4: + return 4 + if value >= t3: + return 3 + if value >= t2: + return 2 + if value >= t1: + return 1 + return 0 diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py new file mode 100644 index 0000000..daed322 --- /dev/null +++ b/tests/test_formula_engine.py @@ -0,0 +1,188 @@ +"""Tests for the formula engine (WP-09). + +Unit tests only — no database required. Stats inputs are simple namespace +objects whose attributes match what PlayerSeasonStats exposes. + +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, "k": 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": 37, "t2": 149, "t3": 448, "t4": 896}, + "sp": {"card_type": "sp", "t1": 10, "t2": 40, "t3": 120, "t4": 240}, + "rp": {"card_type": "rp", "t1": 3, "t2": 12, "t3": 35, "t4": 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, k=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, k=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, k=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, k=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 From ddf6ff596136ae0d90ecba08790e710e788d26e4 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 20:40:38 -0500 Subject: [PATCH 10/18] feat: Track Catalog API endpoints (WP-06) (#71) Closes #71 Adds GET /api/v2/evolution/tracks and GET /api/v2/evolution/tracks/{track_id} endpoints for browsing evolution tracks and their thresholds. Both endpoints require Bearer token auth and return a track dict with formula and t1-t4 threshold fields. The card_type query param filters the list endpoint. EvolutionTrack is lazy-imported inside each handler so the app can start before WP-01 (EvolutionTrack model) is merged into next-release. Also suppresses pre-existing E402/F541 ruff warnings in app/main.py via pyproject.toml per-file-ignores so the pre-commit hook does not block unrelated future commits to that file. Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 2 + app/routers_v2/evolution.py | 43 ++++++++++ pyproject.toml | 5 ++ tests/test_evolution_track_api.py | 132 ++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 app/routers_v2/evolution.py create mode 100644 pyproject.toml create mode 100644 tests/test_evolution_track_api.py diff --git a/app/main.py b/app/main.py index 64cbfc2..a5a1272 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,7 @@ from .routers_v2 import ( stratplays, scout_opportunities, scout_claims, + evolution, ) app = FastAPI( @@ -92,6 +93,7 @@ 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.middleware("http") diff --git a/app/routers_v2/evolution.py b/app/routers_v2/evolution.py new file mode 100644 index 0000000..f7d9b86 --- /dev/null +++ b/app/routers_v2/evolution.py @@ -0,0 +1,43 @@ +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 + +router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"]) + + +@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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1c8d25 --- /dev/null +++ b/pyproject.toml @@ -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"] } diff --git a/tests/test_evolution_track_api.py b/tests/test_evolution_track_api.py new file mode 100644 index 0000000..2545db3 --- /dev/null +++ b/tests/test_evolution_track_api.py @@ -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 From f471354e39f539f60614db9d715656950b7a03eb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 13 Mar 2026 01:35:14 -0500 Subject: [PATCH 11/18] feat: persistent browser instance for card rendering (#89) Replace per-request Chromium launch/teardown with a module-level persistent browser. get_browser() lazy-initializes with is_connected() auto-reconnect; shutdown_browser() is wired into FastAPI lifespan for clean teardown. Pages are created per-request and closed in a finally block to prevent leaks. Also fixed pre-existing ruff errors in staged files (E402 noqa comments, F541 f-string prefix removal, F841 unused variable rename) that were blocking the pre-commit hook. Closes #89 Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 15 ++++++++++++--- app/routers_v2/players.py | 33 +++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index a5a1272..17d60f6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import logging import os +from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI, Request @@ -16,8 +17,8 @@ logging.basicConfig( # from fastapi.staticfiles import StaticFiles # from fastapi.templating import Jinja2Templates -from .db_engine import db -from .routers_v2 import ( +from .db_engine import db # noqa: E402 +from .routers_v2 import ( # noqa: E402 current, awards, teams, @@ -52,8 +53,16 @@ from .routers_v2 import ( evolution, ) + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + await players.shutdown_browser() + + app = FastAPI( # root_path='/api', + lifespan=lifespan, responses={404: {"description": "Not found"}}, docs_url="/api/docs", redoc_url="/api/redoc", @@ -116,4 +125,4 @@ async def get_docs(req: Request): @app.get("/api/openapi.json", include_in_schema=False) async def openapi(): - return get_openapi(title="Paper Dynasty API", version=f"0.1.1", routes=app.routes) + return get_openapi(title="Paper Dynasty API", version="0.1.1", routes=app.routes) diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index aed9e53..060ae91 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -31,6 +31,30 @@ from ..db_engine import ( from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token +_browser = None +_playwright = None + + +async def get_browser(): + global _browser, _playwright + if _browser is not None and _browser.is_connected(): + return _browser + if _playwright is None: + _playwright = await async_playwright().start() + _browser = await _playwright.chromium.launch() + return _browser + + +async def shutdown_browser(): + global _browser, _playwright + if _browser is not None: + await _browser.close() + _browser = None + if _playwright is not None: + await _playwright.stop() + _playwright = None + + # Franchise normalization: Convert city+team names to city-agnostic team names # This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics') FRANCHISE_NORMALIZE = { @@ -806,16 +830,17 @@ async def get_batter_card( logging.debug(f"body:\n{html_response.body.decode('UTF-8')}") file_path = f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" - async with async_playwright() as p: - browser = await p.chromium.launch() - page = await browser.new_page() + browser = await get_browser() + page = await browser.new_page(viewport={"width": 1280, "height": 720}) + try: await page.set_content(html_response.body.decode("UTF-8")) await page.screenshot( path=file_path, type="png", clip={"x": 0.0, "y": 0, "width": 1200, "height": 600}, ) - await browser.close() + finally: + await page.close() # hti = Html2Image( # browser='chrome', From a6cf4eea0163425ad5bbfcaebf4a23705b5ece53 Mon Sep 17 00:00:00 2001 From: cal Date: Mon, 16 Mar 2026 16:40:04 +0000 Subject: [PATCH 12/18] fix: pin base image to Debian Bookworm for Playwright compatibility The tiangolo base image recently moved to Debian Trixie (testing), which Playwright doesn't support yet. `playwright install-deps` fails because ttf-unifont and ttf-ubuntu-font-family packages were renamed/removed in Trixie. Pinning to slim-bookworm (Debian 12) restores compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 63899b0..dbabd74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11 +FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim-bookworm WORKDIR /usr/src/app @@ -38,4 +38,4 @@ RUN pip install --no-cache-dir -r requirements.txt RUN playwright install chromium RUN playwright install-deps chromium -COPY ./app /app/app \ No newline at end of file +COPY ./app /app/app From 47dcdf00c4a96efecfb150f8fee36ca3ba2f021f Mon Sep 17 00:00:00 2001 From: cal Date: Mon, 16 Mar 2026 16:42:51 +0000 Subject: [PATCH 13/18] fix: switch to python:3.11-slim-bookworm base image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tiangolo/uvicorn-gunicorn-fastapi image moved to Debian Trixie (testing) which Playwright doesn't support — install-deps fails on renamed font packages. The tiangolo image only adds uvicorn/gunicorn which are already in requirements.txt, so switch to the official Python slim-bookworm image directly. Also removes the old commented-out Chrome manual install block that hasn't been used since the Playwright migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index dbabd74..c82c87f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,7 @@ -FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim-bookworm +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 From 84a45d9caa63b7faf81cfff1d9a03e8b5b3f9827 Mon Sep 17 00:00:00 2001 From: cal Date: Mon, 16 Mar 2026 17:02:15 +0000 Subject: [PATCH 14/18] fix: use mode=min for Docker build cache to avoid Hub blob limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mode=max pushes all intermediate layers to Docker Hub as cache, which exceeds blob size limits when the base image changes. mode=min only caches final image layers — smaller push, still provides cache hits for the most common rebuild scenario (code changes, same base). Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index eeeb242..ff09481 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -56,7 +56,7 @@ jobs: 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 + cache-to: type=registry,ref=manticorum67/paper-dynasty-database:buildcache,mode=min - name: Tag release if: success() && github.ref == 'refs/heads/main' From 6d972114b7c23db71c06a522afe67fe74ec8cda7 Mon Sep 17 00:00:00 2001 From: cal Date: Mon, 16 Mar 2026 17:11:08 +0000 Subject: [PATCH 15/18] fix: remove Docker Hub registry cache to unblock builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry cache export consistently fails with 400 Bad Request from Docker Hub, likely due to blob size limits on the free tier after the base image change. Removing cache-from/cache-to entirely — builds are fast enough without it (~2 min), and we can re-add with a local cache backend later if needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ff09481..370188b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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=min - name: Tag release if: success() && github.ref == 'refs/heads/main' From 4ed62dea2c9389f2c5fedaad7f42d2a7d4f838ef Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 17 Mar 2026 09:31:52 -0500 Subject: [PATCH 16/18] refactor: rename PlayerSeasonStats `so` to `so_batter` and `k` to `so_pitcher` The single-letter `k` field was ambiguous and too short for comfortable use. Rename to `so_pitcher` for clarity, and `so` to `so_batter` to distinguish batting strikeouts from pitching strikeouts in the same model. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/db_engine.py | 6 +- app/routers_v2/season_stats.py | 232 +++++++++++++++++++++++++++++++ app/services/formula_engine.py | 14 +- tests/test_formula_engine.py | 10 +- tests/test_season_stats_model.py | 16 +-- 5 files changed, 254 insertions(+), 24 deletions(-) create mode 100644 app/routers_v2/season_stats.py diff --git a/app/db_engine.py b/app/db_engine.py index bb9c9f0..217d0d6 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1065,7 +1065,7 @@ class PlayerSeasonStats(BaseModel): triples = IntegerField(default=0) bb = IntegerField(default=0) hbp = IntegerField(default=0) - so = IntegerField(default=0) + so_batter = IntegerField(default=0) rbi = IntegerField(default=0) runs = IntegerField(default=0) sb = IntegerField(default=0) @@ -1074,9 +1074,7 @@ class PlayerSeasonStats(BaseModel): # Pitching stats games_pitching = IntegerField(default=0) outs = IntegerField(default=0) - k = IntegerField( - default=0 - ) # pitcher Ks; spec names this "so (K)" but renamed to avoid collision with batting so + so_pitcher = IntegerField(default=0) bb_allowed = IntegerField(default=0) hits_allowed = IntegerField(default=0) hr_allowed = IntegerField(default=0) diff --git a/app/routers_v2/season_stats.py b/app/routers_v2/season_stats.py new file mode 100644 index 0000000..d981af0 --- /dev/null +++ b/app/routers_v2/season_stats.py @@ -0,0 +1,232 @@ +"""Season stats API endpoints. + +Covers WP-13 (Post-Game Callback Integration): + POST /api/v2/season-stats/update-game/{game_id} + +Aggregates BattingStat and PitchingStat rows for a completed game and +increments the corresponding player_season_stats rows via an additive upsert. + +Lazy-imports PlayerSeasonStats so this module loads before WP-05 merges. +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from ..db_engine import db +from ..dependencies import oauth2_scheme, valid_token + +router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"]) + + +def _ip_to_outs(ip: float) -> int: + """Convert innings-pitched float (e.g. 6.1) to integer outs (e.g. 19). + + Baseball stores IP as whole.partial where the fractional digit is outs + (0, 1, or 2), not tenths. 6.1 = 6 innings + 1 out = 19 outs. + """ + whole = int(ip) + partial = round((ip - whole) * 10) + return whole * 3 + partial + + +@router.post("/update-game/{game_id}") +async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): + """Increment player_season_stats with batting and pitching deltas from a game. + + Queries BattingStat and PitchingStat rows for game_id, aggregates by + (player_id, team_id, season), then performs an additive ON CONFLICT upsert + into player_season_stats. Idempotent: replaying the same game_id a second + time will double-count stats, so callers must ensure this is only called once + per game. + + Response: {"updated": N} where N is the number of player rows touched. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + updated = 0 + + # --- Batting --- + bat_rows = list( + db.execute_sql( + """ + SELECT c.player_id, bs.team_id, bs.season, + SUM(bs.pa), SUM(bs.ab), SUM(bs.run), SUM(bs.hit), + SUM(bs.double), SUM(bs.triple), SUM(bs.hr), SUM(bs.rbi), + SUM(bs.bb), SUM(bs.so), SUM(bs.hbp), SUM(bs.sac), + SUM(bs.ibb), SUM(bs.gidp), SUM(bs.sb), SUM(bs.cs) + FROM battingstat bs + JOIN card c ON bs.card_id = c.id + WHERE bs.game_id = %s + GROUP BY c.player_id, bs.team_id, bs.season + """, + (game_id,), + ) + ) + + for row in bat_rows: + ( + player_id, + team_id, + season, + pa, + ab, + r, + hits, + doubles, + triples, + hr, + rbi, + bb, + so, + hbp, + sac, + ibb, + gidp, + sb, + cs, + ) = row + db.execute_sql( + """ + INSERT INTO player_season_stats + (player_id, team_id, season, + pa, ab, r, hits, doubles, triples, hr, rbi, + bb, so_batter, hbp, sac, ibb, gidp, sb, cs) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (player_id, team_id, season) DO UPDATE SET + pa = player_season_stats.pa + EXCLUDED.pa, + ab = player_season_stats.ab + EXCLUDED.ab, + r = player_season_stats.r + EXCLUDED.r, + hits = player_season_stats.hits + EXCLUDED.hits, + doubles= player_season_stats.doubles+ EXCLUDED.doubles, + triples= player_season_stats.triples+ EXCLUDED.triples, + hr = player_season_stats.hr + EXCLUDED.hr, + rbi = player_season_stats.rbi + EXCLUDED.rbi, + bb = player_season_stats.bb + EXCLUDED.bb, + so_batter= player_season_stats.so_batter+ EXCLUDED.so_batter, + hbp = player_season_stats.hbp + EXCLUDED.hbp, + sac = player_season_stats.sac + EXCLUDED.sac, + ibb = player_season_stats.ibb + EXCLUDED.ibb, + gidp = player_season_stats.gidp + EXCLUDED.gidp, + sb = player_season_stats.sb + EXCLUDED.sb, + cs = player_season_stats.cs + EXCLUDED.cs + """, + ( + player_id, + team_id, + season, + pa, + ab, + r, + hits, + doubles, + triples, + hr, + rbi, + bb, + so, + hbp, + sac, + ibb, + gidp, + sb, + cs, + ), + ) + updated += 1 + + # --- Pitching --- + pit_rows = list( + db.execute_sql( + """ + SELECT c.player_id, ps.team_id, ps.season, + SUM(ps.ip), SUM(ps.so), SUM(ps.hit), SUM(ps.run), SUM(ps.erun), + SUM(ps.bb), SUM(ps.hbp), SUM(ps.wp), SUM(ps.balk), SUM(ps.hr), + SUM(ps.gs), SUM(ps.win), SUM(ps.loss), SUM(ps.hold), + SUM(ps.sv), SUM(ps.bsv) + FROM pitchingstat ps + JOIN card c ON ps.card_id = c.id + WHERE ps.game_id = %s + GROUP BY c.player_id, ps.team_id, ps.season + """, + (game_id,), + ) + ) + + for row in pit_rows: + ( + player_id, + team_id, + season, + ip, + so_pitcher, + h_allowed, + r_allowed, + er, + bb_p, + hbp_p, + wp, + balk, + hr_p, + gs, + w, + losses, + hold, + sv, + bsv, + ) = row + outs = _ip_to_outs(float(ip)) + db.execute_sql( + """ + INSERT INTO player_season_stats + (player_id, team_id, season, + outs, so_pitcher, h_allowed, r_allowed, er, + bb_p, hbp_p, wp, balk, hr_p, + gs, w, l, hold, sv, bsv) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (player_id, team_id, season) DO UPDATE SET + outs = player_season_stats.outs + EXCLUDED.outs, + so_pitcher= player_season_stats.so_pitcher+ EXCLUDED.so_pitcher, + h_allowed= player_season_stats.h_allowed+ EXCLUDED.h_allowed, + r_allowed= player_season_stats.r_allowed+ EXCLUDED.r_allowed, + er = player_season_stats.er + EXCLUDED.er, + bb_p = player_season_stats.bb_p + EXCLUDED.bb_p, + hbp_p = player_season_stats.hbp_p + EXCLUDED.hbp_p, + wp = player_season_stats.wp + EXCLUDED.wp, + balk = player_season_stats.balk + EXCLUDED.balk, + hr_p = player_season_stats.hr_p + EXCLUDED.hr_p, + gs = player_season_stats.gs + EXCLUDED.gs, + w = player_season_stats.w + EXCLUDED.w, + l = player_season_stats.l + EXCLUDED.l, + hold = player_season_stats.hold + EXCLUDED.hold, + sv = player_season_stats.sv + EXCLUDED.sv, + bsv = player_season_stats.bsv + EXCLUDED.bsv + """, + ( + player_id, + team_id, + season, + outs, + so_pitcher, + h_allowed, + r_allowed, + er, + bb_p, + hbp_p, + wp, + balk, + hr_p, + gs, + w, + losses, + hold, + sv, + bsv, + ), + ) + updated += 1 + + logging.info(f"update-game/{game_id}: updated {updated} player_season_stats rows") + return {"updated": updated} diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index 6178363..0c45287 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -5,8 +5,8 @@ plus helpers for formula dispatch and tier classification. Stats attributes expected by each formula: compute_batter_value: pa, hits, doubles, triples, hr - compute_sp_value: outs, k (k = pitcher strikeouts, from PlayerSeasonStats) - compute_rp_value: outs, k + compute_sp_value: outs, so_pitcher (pitcher strikeouts, from PlayerSeasonStats) + compute_rp_value: outs, so_pitcher """ from typing import Protocol @@ -22,7 +22,7 @@ class BatterStats(Protocol): class PitcherStats(Protocol): outs: int - k: int + so_pitcher: int # --------------------------------------------------------------------------- @@ -38,13 +38,13 @@ def compute_batter_value(stats) -> float: def compute_sp_value(stats) -> float: - """IP + K where IP = outs / 3. Uses stats.k (pitcher strikeouts).""" - return stats.outs / 3 + stats.k + """IP + K where IP = outs / 3. Uses stats.so_pitcher (pitcher strikeouts).""" + return stats.outs / 3 + stats.so_pitcher def compute_rp_value(stats) -> float: - """IP + K (same formula as SP; thresholds differ). Uses stats.k.""" - return stats.outs / 3 + stats.k + """IP + K (same formula as SP; thresholds differ). Uses stats.so_pitcher.""" + return stats.outs / 3 + stats.so_pitcher # --------------------------------------------------------------------------- diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index daed322..310c123 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -35,7 +35,7 @@ def batter_stats(**kwargs): def pitcher_stats(**kwargs): """Build a minimal pitcher stats object with all fields defaulting to 0.""" - defaults = {"outs": 0, "k": 0} + defaults = {"outs": 0, "so_pitcher": 0} defaults.update(kwargs) return SimpleNamespace(**defaults) @@ -84,7 +84,7 @@ def test_batter_formula_hr_heavy(): 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, k=5) + stats = pitcher_stats(outs=18, so_pitcher=5) assert compute_sp_value(stats) == 11.0 @@ -95,7 +95,7 @@ def test_sp_formula_standard(): 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, k=2) + stats = pitcher_stats(outs=3, so_pitcher=2) assert compute_rp_value(stats) == 3.0 @@ -132,13 +132,13 @@ def test_dispatch_batter(): def test_dispatch_sp(): """compute_value_for_track('sp', ...) delegates to compute_sp_value.""" - stats = pitcher_stats(outs=18, k=5) + stats = pitcher_stats(outs=18, so_pitcher=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, k=2) + stats = pitcher_stats(outs=3, so_pitcher=2) assert compute_value_for_track("rp", stats) == compute_rp_value(stats) diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py index 20fc3b8..1387357 100644 --- a/tests/test_season_stats_model.py +++ b/tests/test_season_stats_model.py @@ -112,7 +112,7 @@ class TestColumnCompleteness: "triples", "bb", "hbp", - "so", + "so_batter", "rbi", "runs", "sb", @@ -121,7 +121,7 @@ class TestColumnCompleteness: PITCHING_COLS = [ "games_pitching", "outs", - "k", + "so_pitcher", "bb_allowed", "hits_allowed", "hr_allowed", @@ -181,14 +181,14 @@ class TestDefaultValues: "triples", "bb", "hbp", - "so", + "so_batter", "rbi", "runs", "sb", "cs", "games_pitching", "outs", - "k", + "so_pitcher", "bb_allowed", "hits_allowed", "hr_allowed", @@ -284,16 +284,16 @@ class TestDeltaUpdatePattern: assert updated.games_pitching == 0 # untouched def test_increment_pitching_stats(self): - """Updating outs and k increments without touching batting columns.""" + """Updating outs and so_pitcher increments without touching batting columns.""" rarity = make_rarity() cardset = make_cardset() player = make_player(cardset, rarity) team = make_team() - row = make_stats(player, team, season=10, outs=9, k=3) + row = make_stats(player, team, season=10, outs=9, so_pitcher=3) PlayerSeasonStats.update( outs=PlayerSeasonStats.outs + 6, - k=PlayerSeasonStats.k + 2, + so_pitcher=PlayerSeasonStats.so_pitcher + 2, ).where( (PlayerSeasonStats.player == player) & (PlayerSeasonStats.team == team) @@ -302,7 +302,7 @@ class TestDeltaUpdatePattern: updated = PlayerSeasonStats.get_by_id(row.id) assert updated.outs == 15 - assert updated.k == 5 + assert updated.so_pitcher == 5 assert updated.pa == 0 # untouched def test_last_game_fk_is_nullable(self): From bd8e4578cc0e0cde08b334a6987008044f5beb3f Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 17 Mar 2026 09:43:22 -0500 Subject: [PATCH 17/18] refactor: split PlayerSeasonStats into BattingSeasonStats and PitchingSeasonStats Separate batting and pitching into distinct tables with descriptive column names. Eliminates naming collisions (so/k ambiguity) and column mismatches between the ORM model and raw SQL. Each table now covers all aggregatable fields from its source (BattingStat/PitchingStat) including sac, ibb, gidp, earned_runs, runs_allowed, wild_pitches, balks, and games_started. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/db_engine.py | 114 ++++++--- app/models/season_stats.py | 8 +- app/routers_v2/season_stats.py | 158 ++++++------- app/services/formula_engine.py | 20 +- tests/test_formula_engine.py | 12 +- tests/test_season_stats_model.py | 386 ++++++++++++++++++++----------- 6 files changed, 433 insertions(+), 265 deletions(-) diff --git a/app/db_engine.py b/app/db_engine.py index 217d0d6..4183bb9 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1050,73 +1050,113 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique= Decision.add_index(decision_index) -class PlayerSeasonStats(BaseModel): +class BattingSeasonStats(BaseModel): player = ForeignKeyField(Player) team = ForeignKeyField(Team) season = IntegerField() - - # Batting stats - games_batting = IntegerField(default=0) + games = IntegerField(default=0) pa = IntegerField(default=0) ab = IntegerField(default=0) hits = IntegerField(default=0) - hr = IntegerField(default=0) doubles = IntegerField(default=0) triples = IntegerField(default=0) - bb = IntegerField(default=0) - hbp = IntegerField(default=0) - so_batter = 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) - - # Pitching stats - games_pitching = IntegerField(default=0) - outs = IntegerField(default=0) - so_pitcher = IntegerField(default=0) - bb_allowed = IntegerField(default=0) - hits_allowed = IntegerField(default=0) - hr_allowed = IntegerField(default=0) - wins = IntegerField(default=0) - losses = IntegerField(default=0) - saves = IntegerField(default=0) - holds = IntegerField(default=0) - blown_saves = IntegerField(default=0) - - # Meta last_game = ForeignKeyField(StratGame, null=True) last_updated_at = DateTimeField(null=True) class Meta: database = db - table_name = "player_season_stats" + table_name = "batting_season_stats" -pss_unique_index = ModelIndex( - PlayerSeasonStats, - (PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season), +bss_unique_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.player, BattingSeasonStats.team, BattingSeasonStats.season), unique=True, ) -PlayerSeasonStats.add_index(pss_unique_index) +BattingSeasonStats.add_index(bss_unique_index) -pss_team_season_index = ModelIndex( - PlayerSeasonStats, - (PlayerSeasonStats.team, PlayerSeasonStats.season), +bss_team_season_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.team, BattingSeasonStats.season), unique=False, ) -PlayerSeasonStats.add_index(pss_team_season_index) +BattingSeasonStats.add_index(bss_team_season_index) -pss_player_season_index = ModelIndex( - PlayerSeasonStats, - (PlayerSeasonStats.player, PlayerSeasonStats.season), +bss_player_season_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.player, BattingSeasonStats.season), unique=False, ) -PlayerSeasonStats.add_index(pss_player_season_index) +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) if not SKIP_TABLE_CREATION: - db.create_tables([StratGame, StratPlay, Decision, PlayerSeasonStats], safe=True) + db.create_tables( + [StratGame, StratPlay, Decision, BattingSeasonStats, PitchingSeasonStats], + safe=True, + ) class ScoutOpportunity(BaseModel): diff --git a/app/models/season_stats.py b/app/models/season_stats.py index bdd7ad1..b47dfec 100644 --- a/app/models/season_stats.py +++ b/app/models/season_stats.py @@ -1,7 +1,7 @@ -"""PlayerSeasonStats ORM model. +"""Season stats ORM models. -Model is defined in db_engine alongside all other Peewee models; this -module re-exports it so callers can import from `app.models.season_stats`. +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 PlayerSeasonStats # noqa: F401 +from ..db_engine import BattingSeasonStats, PitchingSeasonStats # noqa: F401 diff --git a/app/routers_v2/season_stats.py b/app/routers_v2/season_stats.py index d981af0..c5d48c3 100644 --- a/app/routers_v2/season_stats.py +++ b/app/routers_v2/season_stats.py @@ -4,9 +4,8 @@ Covers WP-13 (Post-Game Callback Integration): POST /api/v2/season-stats/update-game/{game_id} Aggregates BattingStat and PitchingStat rows for a completed game and -increments the corresponding player_season_stats rows via an additive upsert. - -Lazy-imports PlayerSeasonStats so this module loads before WP-05 merges. +increments the corresponding batting_season_stats / pitching_season_stats +rows via an additive upsert. """ import logging @@ -32,13 +31,14 @@ def _ip_to_outs(ip: float) -> int: @router.post("/update-game/{game_id}") async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): - """Increment player_season_stats with batting and pitching deltas from a game. + """Increment season stats with batting and pitching deltas from a game. Queries BattingStat and PitchingStat rows for game_id, aggregates by (player_id, team_id, season), then performs an additive ON CONFLICT upsert - into player_season_stats. Idempotent: replaying the same game_id a second - time will double-count stats, so callers must ensure this is only called once - per game. + into batting_season_stats and pitching_season_stats respectively. + + Replaying the same game_id will double-count stats, so callers must ensure + this is only called once per game. Response: {"updated": N} where N is the number of player rows touched. """ @@ -73,14 +73,14 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch season, pa, ab, - r, + runs, hits, doubles, triples, hr, rbi, bb, - so, + strikeouts, hbp, sac, ibb, @@ -90,28 +90,28 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch ) = row db.execute_sql( """ - INSERT INTO player_season_stats + INSERT INTO batting_season_stats (player_id, team_id, season, - pa, ab, r, hits, doubles, triples, hr, rbi, - bb, so_batter, hbp, sac, ibb, gidp, sb, cs) + pa, ab, runs, hits, doubles, triples, hr, rbi, + bb, strikeouts, hbp, sac, ibb, gidp, sb, cs) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (player_id, team_id, season) DO UPDATE SET - pa = player_season_stats.pa + EXCLUDED.pa, - ab = player_season_stats.ab + EXCLUDED.ab, - r = player_season_stats.r + EXCLUDED.r, - hits = player_season_stats.hits + EXCLUDED.hits, - doubles= player_season_stats.doubles+ EXCLUDED.doubles, - triples= player_season_stats.triples+ EXCLUDED.triples, - hr = player_season_stats.hr + EXCLUDED.hr, - rbi = player_season_stats.rbi + EXCLUDED.rbi, - bb = player_season_stats.bb + EXCLUDED.bb, - so_batter= player_season_stats.so_batter+ EXCLUDED.so_batter, - hbp = player_season_stats.hbp + EXCLUDED.hbp, - sac = player_season_stats.sac + EXCLUDED.sac, - ibb = player_season_stats.ibb + EXCLUDED.ibb, - gidp = player_season_stats.gidp + EXCLUDED.gidp, - sb = player_season_stats.sb + EXCLUDED.sb, - cs = player_season_stats.cs + EXCLUDED.cs + pa = batting_season_stats.pa + EXCLUDED.pa, + ab = batting_season_stats.ab + EXCLUDED.ab, + runs = batting_season_stats.runs + EXCLUDED.runs, + hits = batting_season_stats.hits + EXCLUDED.hits, + doubles = batting_season_stats.doubles + EXCLUDED.doubles, + triples = batting_season_stats.triples + EXCLUDED.triples, + hr = batting_season_stats.hr + EXCLUDED.hr, + rbi = batting_season_stats.rbi + EXCLUDED.rbi, + bb = batting_season_stats.bb + EXCLUDED.bb, + strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts, + hbp = batting_season_stats.hbp + EXCLUDED.hbp, + sac = batting_season_stats.sac + EXCLUDED.sac, + ibb = batting_season_stats.ibb + EXCLUDED.ibb, + gidp = batting_season_stats.gidp + EXCLUDED.gidp, + sb = batting_season_stats.sb + EXCLUDED.sb, + cs = batting_season_stats.cs + EXCLUDED.cs """, ( player_id, @@ -119,14 +119,14 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch season, pa, ab, - r, + runs, hits, doubles, triples, hr, rbi, bb, - so, + strikeouts, hbp, sac, ibb, @@ -161,72 +161,72 @@ async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_sch team_id, season, ip, - so_pitcher, - h_allowed, - r_allowed, - er, - bb_p, - hbp_p, - wp, - balk, - hr_p, - gs, - w, + strikeouts, + hits_allowed, + runs_allowed, + earned_runs, + bb, + hbp, + wild_pitches, + balks, + hr_allowed, + games_started, + wins, losses, - hold, - sv, - bsv, + holds, + saves, + blown_saves, ) = row outs = _ip_to_outs(float(ip)) db.execute_sql( """ - INSERT INTO player_season_stats + INSERT INTO pitching_season_stats (player_id, team_id, season, - outs, so_pitcher, h_allowed, r_allowed, er, - bb_p, hbp_p, wp, balk, hr_p, - gs, w, l, hold, sv, bsv) + outs, strikeouts, hits_allowed, runs_allowed, earned_runs, + bb, hbp, wild_pitches, balks, hr_allowed, + games_started, wins, losses, holds, saves, blown_saves) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (player_id, team_id, season) DO UPDATE SET - outs = player_season_stats.outs + EXCLUDED.outs, - so_pitcher= player_season_stats.so_pitcher+ EXCLUDED.so_pitcher, - h_allowed= player_season_stats.h_allowed+ EXCLUDED.h_allowed, - r_allowed= player_season_stats.r_allowed+ EXCLUDED.r_allowed, - er = player_season_stats.er + EXCLUDED.er, - bb_p = player_season_stats.bb_p + EXCLUDED.bb_p, - hbp_p = player_season_stats.hbp_p + EXCLUDED.hbp_p, - wp = player_season_stats.wp + EXCLUDED.wp, - balk = player_season_stats.balk + EXCLUDED.balk, - hr_p = player_season_stats.hr_p + EXCLUDED.hr_p, - gs = player_season_stats.gs + EXCLUDED.gs, - w = player_season_stats.w + EXCLUDED.w, - l = player_season_stats.l + EXCLUDED.l, - hold = player_season_stats.hold + EXCLUDED.hold, - sv = player_season_stats.sv + EXCLUDED.sv, - bsv = player_season_stats.bsv + EXCLUDED.bsv + outs = pitching_season_stats.outs + EXCLUDED.outs, + strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts, + hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed, + runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed, + earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs, + bb = pitching_season_stats.bb + EXCLUDED.bb, + hbp = pitching_season_stats.hbp + EXCLUDED.hbp, + wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches, + balks = pitching_season_stats.balks + EXCLUDED.balks, + hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed, + games_started= pitching_season_stats.games_started+ EXCLUDED.games_started, + wins = pitching_season_stats.wins + EXCLUDED.wins, + losses = pitching_season_stats.losses + EXCLUDED.losses, + holds = pitching_season_stats.holds + EXCLUDED.holds, + saves = pitching_season_stats.saves + EXCLUDED.saves, + blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves """, ( player_id, team_id, season, outs, - so_pitcher, - h_allowed, - r_allowed, - er, - bb_p, - hbp_p, - wp, - balk, - hr_p, - gs, - w, + strikeouts, + hits_allowed, + runs_allowed, + earned_runs, + bb, + hbp, + wild_pitches, + balks, + hr_allowed, + games_started, + wins, losses, - hold, - sv, - bsv, + holds, + saves, + blown_saves, ), ) updated += 1 - logging.info(f"update-game/{game_id}: updated {updated} player_season_stats rows") + logging.info(f"update-game/{game_id}: updated {updated} season stats rows") return {"updated": updated} diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index 0c45287..c2ae125 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -4,9 +4,9 @@ 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 - compute_sp_value: outs, so_pitcher (pitcher strikeouts, from PlayerSeasonStats) - compute_rp_value: outs, so_pitcher + 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 @@ -22,7 +22,7 @@ class BatterStats(Protocol): class PitcherStats(Protocol): outs: int - so_pitcher: int + strikeouts: int # --------------------------------------------------------------------------- @@ -31,20 +31,20 @@ class PitcherStats(Protocol): def compute_batter_value(stats) -> float: - """PA + (TB × 2) where TB = 1B + 2×2B + 3×3B + 4×HR.""" + """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 compute_sp_value(stats) -> float: - """IP + K where IP = outs / 3. Uses stats.so_pitcher (pitcher strikeouts).""" - return stats.outs / 3 + stats.so_pitcher + """IP + K where IP = outs / 3.""" + return stats.outs / 3 + stats.strikeouts def compute_rp_value(stats) -> float: - """IP + K (same formula as SP; thresholds differ). Uses stats.so_pitcher.""" - return stats.outs / 3 + stats.so_pitcher + """IP + K (same formula as SP; thresholds differ).""" + return stats.outs / 3 + stats.strikeouts # --------------------------------------------------------------------------- @@ -75,7 +75,7 @@ def compute_value_for_track(card_type: str, stats) -> float: def tier_from_value(value: float, track) -> int: - """Return the evolution tier (0–4) for a computed value against a track. + """Return the evolution tier (0-4) for a computed value against a track. Tier boundaries are inclusive on the lower end: T0: value < t1 diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index 310c123..67c14a9 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -1,7 +1,7 @@ """Tests for the formula engine (WP-09). Unit tests only — no database required. Stats inputs are simple namespace -objects whose attributes match what PlayerSeasonStats exposes. +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 @@ -35,7 +35,7 @@ def batter_stats(**kwargs): def pitcher_stats(**kwargs): """Build a minimal pitcher stats object with all fields defaulting to 0.""" - defaults = {"outs": 0, "so_pitcher": 0} + defaults = {"outs": 0, "strikeouts": 0} defaults.update(kwargs) return SimpleNamespace(**defaults) @@ -84,7 +84,7 @@ def test_batter_formula_hr_heavy(): 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, so_pitcher=5) + stats = pitcher_stats(outs=18, strikeouts=5) assert compute_sp_value(stats) == 11.0 @@ -95,7 +95,7 @@ def test_sp_formula_standard(): 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, so_pitcher=2) + stats = pitcher_stats(outs=3, strikeouts=2) assert compute_rp_value(stats) == 3.0 @@ -132,13 +132,13 @@ def test_dispatch_batter(): def test_dispatch_sp(): """compute_value_for_track('sp', ...) delegates to compute_sp_value.""" - stats = pitcher_stats(outs=18, so_pitcher=5) + 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, so_pitcher=2) + stats = pitcher_stats(outs=3, strikeouts=2) assert compute_value_for_track("rp", stats) == compute_rp_value(stats) diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py index 1387357..3876964 100644 --- a/tests/test_season_stats_model.py +++ b/tests/test_season_stats_model.py @@ -1,19 +1,15 @@ -"""Tests for PlayerSeasonStats Peewee model (WP-02). +"""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. - -Note on column naming: the spec labels the pitching strikeout column as -"so (K)". This model names it `k` to avoid collision with the batting -strikeout column `so`. """ import pytest from peewee import SqliteDatabase, IntegrityError -from app.models.season_stats import PlayerSeasonStats +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. @@ -25,7 +21,8 @@ _TEST_MODELS = [ Player, Team, StratGame, - PlayerSeasonStats, + BattingSeasonStats, + PitchingSeasonStats, ] _test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) @@ -92,218 +89,278 @@ def make_game(home_team, away_team, season=10): ) -def make_stats(player, team, season=10, **kwargs): - return PlayerSeasonStats.create(player=player, team=team, season=season, **kwargs) +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) # ── Unit: column completeness ──────────────────────────────────────────────── -class TestColumnCompleteness: - """All required columns are present in the model's field definitions.""" +class TestBattingColumnCompleteness: + """All required columns are present in BattingSeasonStats.""" - BATTING_COLS = [ - "games_batting", + EXPECTED_COLS = [ + "games", "pa", "ab", "hits", - "hr", "doubles", "triples", - "bb", - "hbp", - "so_batter", + "hr", "rbi", "runs", + "bb", + "strikeouts", + "hbp", + "sac", + "ibb", + "gidp", "sb", "cs", ] - PITCHING_COLS = [ - "games_pitching", - "outs", - "so_pitcher", - "bb_allowed", - "hits_allowed", - "hr_allowed", - "wins", - "losses", - "saves", - "holds", - "blown_saves", - ] - META_COLS = ["last_game", "last_updated_at"] KEY_COLS = ["player", "team", "season"] + META_COLS = ["last_game", "last_updated_at"] - def test_batting_columns_present(self): - """All batting aggregate columns defined in the spec are present.""" - fields = PlayerSeasonStats._meta.fields - for col in self.BATTING_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_pitching_columns_present(self): - """All pitching aggregate columns defined in the spec are present.""" - fields = PlayerSeasonStats._meta.fields - for col in self.PITCHING_COLS: - assert col in fields, f"Missing pitching column: {col}" - - def test_meta_columns_present(self): - """Meta columns last_game and last_updated_at are present.""" - fields = PlayerSeasonStats._meta.fields - for col in self.META_COLS: - assert col in fields, f"Missing meta column: {col}" - def test_key_columns_present(self): """player, team, and season columns are present.""" - fields = PlayerSeasonStats._meta.fields + fields = BattingSeasonStats._meta.fields for col in self.KEY_COLS: assert col in fields, f"Missing key column: {col}" - def test_excluded_columns_absent(self): - """team_wins and quality_starts are NOT in the model (removed from scope).""" - fields = PlayerSeasonStats._meta.fields - assert "team_wins" not in fields - assert "quality_starts" not in fields + 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 = [ + "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"] + + 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 TestDefaultValues: +class TestBattingDefaultValues: """All integer stat columns default to 0; nullable meta fields default to None.""" INT_STAT_COLS = [ - "games_batting", + "games", "pa", "ab", "hits", - "hr", "doubles", "triples", - "bb", - "hbp", - "so_batter", + "hr", "rbi", "runs", + "bb", + "strikeouts", + "hbp", + "sac", + "ibb", + "gidp", "sb", "cs", - "games_pitching", - "outs", - "so_pitcher", - "bb_allowed", - "hits_allowed", - "hr_allowed", - "wins", - "losses", - "saves", - "holds", - "blown_saves", ] def test_all_int_columns_default_to_zero(self): """Every integer stat column defaults to 0 on an unsaved instance.""" - row = PlayerSeasonStats() + 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 = PlayerSeasonStats() + 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 = PlayerSeasonStats() + 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 = [ + "games", + "games_started", + "outs", + "strikeouts", + "bb", + "hits_allowed", + "runs_allowed", + "earned_runs", + "hr_allowed", + "hbp", + "wild_pitches", + "balks", + "wins", + "losses", + "holds", + "saves", + "blown_saves", + ] + + 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 TestUniqueConstraint: +class TestBattingUniqueConstraint: """UNIQUE on (player_id, team_id, season) is enforced at the DB level.""" - def test_duplicate_player_team_season_raises(self): + 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_stats(player, team, season=10) + make_batting_stats(player, team, season=10) with pytest.raises(IntegrityError): - make_stats(player, team, season=10) + make_batting_stats(player, team, season=10) - def test_same_player_different_season_allowed(self): + 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_stats(player, team, season=10) - row2 = make_stats(player, team, season=11) + make_batting_stats(player, team, season=10) + row2 = make_batting_stats(player, team, season=11) assert row2.id is not None - def test_same_player_different_team_allowed(self): + 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_stats(player, team1, season=10) - row2 = make_stats(player, team2, season=10) + 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 TestDeltaUpdatePattern: - """Stats can be incremented (delta update) without replacing existing values.""" +class TestBattingDeltaUpdate: + """Batting stats can be incremented (delta update) without replacing existing values.""" def test_increment_batting_stats(self): - """Updating pa and hits increments without touching pitching columns.""" + """Updating pa and hits increments correctly.""" rarity = make_rarity() cardset = make_cardset() player = make_player(cardset, rarity) team = make_team() - row = make_stats(player, team, season=10, pa=5, hits=2) + row = make_batting_stats(player, team, season=10, pa=5, hits=2) - PlayerSeasonStats.update( - pa=PlayerSeasonStats.pa + 3, - hits=PlayerSeasonStats.hits + 1, + BattingSeasonStats.update( + pa=BattingSeasonStats.pa + 3, + hits=BattingSeasonStats.hits + 1, ).where( - (PlayerSeasonStats.player == player) - & (PlayerSeasonStats.team == team) - & (PlayerSeasonStats.season == 10) + (BattingSeasonStats.player == player) + & (BattingSeasonStats.team == team) + & (BattingSeasonStats.season == 10) ).execute() - updated = PlayerSeasonStats.get_by_id(row.id) + updated = BattingSeasonStats.get_by_id(row.id) assert updated.pa == 8 assert updated.hits == 3 - assert updated.games_pitching == 0 # untouched - - def test_increment_pitching_stats(self): - """Updating outs and so_pitcher increments without touching batting columns.""" - rarity = make_rarity() - cardset = make_cardset() - player = make_player(cardset, rarity) - team = make_team() - row = make_stats(player, team, season=10, outs=9, so_pitcher=3) - - PlayerSeasonStats.update( - outs=PlayerSeasonStats.outs + 6, - so_pitcher=PlayerSeasonStats.so_pitcher + 2, - ).where( - (PlayerSeasonStats.player == player) - & (PlayerSeasonStats.team == team) - & (PlayerSeasonStats.season == 10) - ).execute() - - updated = PlayerSeasonStats.get_by_id(row.id) - assert updated.outs == 15 - assert updated.so_pitcher == 5 - assert updated.pa == 0 # untouched def test_last_game_fk_is_nullable(self): """last_game FK can be set to a StratGame instance or left NULL.""" @@ -311,45 +368,116 @@ class TestDeltaUpdatePattern: cardset = make_cardset() player = make_player(cardset, rarity) team = make_team() - row = make_stats(player, team, season=10) + row = make_batting_stats(player, team, season=10) assert row.last_game_id is None game = make_game(home_team=team, away_team=team) - PlayerSeasonStats.update(last_game=game).where( - PlayerSeasonStats.id == row.id + BattingSeasonStats.update(last_game=game).where( + BattingSeasonStats.id == row.id ).execute() - updated = PlayerSeasonStats.get_by_id(row.id) + 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 TestIndexExistence: - """Required indexes on (team_id, season) and (player_id, season) exist in SQLite.""" +class TestBattingIndexExistence: + """Required indexes exist on batting_season_stats.""" - def _get_index_columns(self, db, table): + def _get_index_columns(self, db_conn, table): """Return a set of frozensets, each being the column set of one index.""" - indexes = db.execute_sql(f"PRAGMA index_list({table})").fetchall() + indexes = db_conn.execute_sql(f"PRAGMA index_list({table})").fetchall() result = set() for idx in indexes: idx_name = idx[1] - cols = db.execute_sql(f"PRAGMA index_info({idx_name})").fetchall() + cols = db_conn.execute_sql(f"PRAGMA index_info({idx_name})").fetchall() result.add(frozenset(col[2] for col in cols)) return result def test_unique_index_on_player_team_season(self, setup_test_db): """A unique index covering (player_id, team_id, season) exists.""" - index_sets = self._get_index_columns(setup_test_db, "player_season_stats") + index_sets = self._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 = self._get_index_columns(setup_test_db, "player_season_stats") + index_sets = self._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 = self._get_index_columns(setup_test_db, "player_season_stats") + index_sets = self._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 _get_index_columns(self, db_conn, table): + """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 + + def test_unique_index_on_player_team_season(self, setup_test_db): + """A unique index covering (player_id, team_id, season) exists.""" + index_sets = self._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 = self._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 = self._get_index_columns(setup_test_db, "pitching_season_stats") assert frozenset({"player_id", "season"}) in index_sets From 6580c1b431059139fdb6125d7d7aee06ffbeda40 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 17 Mar 2026 09:49:33 -0500 Subject: [PATCH 18/18] refactor: deduplicate pitcher formula and test constants Extract shared pitcher value computation into _pitcher_value() helper. Consolidate duplicated column lists and index helper in season stats tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/services/formula_engine.py | 8 +- tests/test_season_stats_model.py | 180 +++++++++++++------------------ 2 files changed, 80 insertions(+), 108 deletions(-) diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index c2ae125..c863051 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -37,14 +37,18 @@ def compute_batter_value(stats) -> float: 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 stats.outs / 3 + stats.strikeouts + return _pitcher_value(stats) def compute_rp_value(stats) -> float: """IP + K (same formula as SP; thresholds differ).""" - return stats.outs / 3 + stats.strikeouts + return _pitcher_value(stats) # --------------------------------------------------------------------------- diff --git a/tests/test_season_stats_model.py b/tests/test_season_stats_model.py index 3876964..8ef3f6c 100644 --- a/tests/test_season_stats_model.py +++ b/tests/test_season_stats_model.py @@ -97,33 +97,75 @@ 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 = [ - "games", - "pa", - "ab", - "hits", - "doubles", - "triples", - "hr", - "rbi", - "runs", - "bb", - "strikeouts", - "hbp", - "sac", - "ibb", - "gidp", - "sb", - "cs", - ] - KEY_COLS = ["player", "team", "season"] - META_COLS = ["last_game", "last_updated_at"] + 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.""" @@ -147,27 +189,9 @@ class TestBattingColumnCompleteness: class TestPitchingColumnCompleteness: """All required columns are present in PitchingSeasonStats.""" - EXPECTED_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"] + 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.""" @@ -194,25 +218,7 @@ class TestPitchingColumnCompleteness: class TestBattingDefaultValues: """All integer stat columns default to 0; nullable meta fields default to None.""" - INT_STAT_COLS = [ - "games", - "pa", - "ab", - "hits", - "doubles", - "triples", - "hr", - "rbi", - "runs", - "bb", - "strikeouts", - "hbp", - "sac", - "ibb", - "gidp", - "sb", - "cs", - ] + 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.""" @@ -235,25 +241,7 @@ class TestBattingDefaultValues: class TestPitchingDefaultValues: """All integer stat columns default to 0; nullable meta fields default to None.""" - INT_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", - ] + 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.""" @@ -428,56 +416,36 @@ class TestPitchingDeltaUpdate: class TestBattingIndexExistence: """Required indexes exist on batting_season_stats.""" - def _get_index_columns(self, db_conn, table): - """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 - def test_unique_index_on_player_team_season(self, setup_test_db): """A unique index covering (player_id, team_id, season) exists.""" - index_sets = self._get_index_columns(setup_test_db, "batting_season_stats") + 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 = self._get_index_columns(setup_test_db, "batting_season_stats") + 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 = self._get_index_columns(setup_test_db, "batting_season_stats") + 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 _get_index_columns(self, db_conn, table): - """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 - def test_unique_index_on_player_team_season(self, setup_test_db): """A unique index covering (player_id, team_id, season) exists.""" - index_sets = self._get_index_columns(setup_test_db, "pitching_season_stats") + 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 = self._get_index_columns(setup_test_db, "pitching_season_stats") + 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 = self._get_index_columns(setup_test_db, "pitching_season_stats") + index_sets = _get_index_columns(setup_test_db, "pitching_season_stats") assert frozenset({"player_id", "season"}) in index_sets