Merge pull request 'refactor: rename Evolution system to Refractor' (#131) from refactor/evolution-to-refractor-rename into main

This commit is contained in:
cal 2026-03-23 19:23:49 +00:00
commit 6a7400484e
20 changed files with 400 additions and 367 deletions

View File

@ -1210,7 +1210,7 @@ if not SKIP_TABLE_CREATION:
db.create_tables([ScoutOpportunity, ScoutClaim], safe=True)
class EvolutionTrack(BaseModel):
class RefractorTrack(BaseModel):
name = CharField(unique=True)
card_type = CharField() # 'batter', 'sp', 'rp'
formula = CharField() # e.g. "pa + tb * 2"
@ -1221,13 +1221,13 @@ class EvolutionTrack(BaseModel):
class Meta:
database = db
table_name = "evolution_track"
table_name = "refractor_track"
class EvolutionCardState(BaseModel):
class RefractorCardState(BaseModel):
player = ForeignKeyField(Player)
team = ForeignKeyField(Team)
track = ForeignKeyField(EvolutionTrack)
track = ForeignKeyField(RefractorTrack)
current_tier = IntegerField(default=0) # 0-4
current_value = FloatField(default=0.0)
fully_evolved = BooleanField(default=False)
@ -1235,19 +1235,19 @@ class EvolutionCardState(BaseModel):
class Meta:
database = db
table_name = "evolution_card_state"
table_name = "refractor_card_state"
evolution_card_state_index = ModelIndex(
EvolutionCardState,
(EvolutionCardState.player, EvolutionCardState.team),
refractor_card_state_index = ModelIndex(
RefractorCardState,
(RefractorCardState.player, RefractorCardState.team),
unique=True,
)
EvolutionCardState.add_index(evolution_card_state_index)
RefractorCardState.add_index(refractor_card_state_index)
class EvolutionTierBoost(BaseModel):
track = ForeignKeyField(EvolutionTrack)
class RefractorTierBoost(BaseModel):
track = ForeignKeyField(RefractorTrack)
tier = IntegerField() # 1-4
boost_type = CharField() # e.g. 'rating', 'stat'
boost_target = CharField() # e.g. 'contact_vl', 'power_vr'
@ -1255,23 +1255,23 @@ class EvolutionTierBoost(BaseModel):
class Meta:
database = db
table_name = "evolution_tier_boost"
table_name = "refractor_tier_boost"
evolution_tier_boost_index = ModelIndex(
EvolutionTierBoost,
refractor_tier_boost_index = ModelIndex(
RefractorTierBoost,
(
EvolutionTierBoost.track,
EvolutionTierBoost.tier,
EvolutionTierBoost.boost_type,
EvolutionTierBoost.boost_target,
RefractorTierBoost.track,
RefractorTierBoost.tier,
RefractorTierBoost.boost_type,
RefractorTierBoost.boost_target,
),
unique=True,
)
EvolutionTierBoost.add_index(evolution_tier_boost_index)
RefractorTierBoost.add_index(refractor_tier_boost_index)
class EvolutionCosmetic(BaseModel):
class RefractorCosmetic(BaseModel):
name = CharField(unique=True)
tier_required = IntegerField(default=0)
cosmetic_type = CharField() # 'frame', 'badge', 'theme'
@ -1280,12 +1280,12 @@ class EvolutionCosmetic(BaseModel):
class Meta:
database = db
table_name = "evolution_cosmetic"
table_name = "refractor_cosmetic"
if not SKIP_TABLE_CREATION:
db.create_tables(
[EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic],
[RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic],
safe=True,
)

View File

@ -51,7 +51,7 @@ from .routers_v2 import ( # noqa: E402
stratplays,
scout_opportunities,
scout_claims,
evolution,
refractor,
season_stats,
)
@ -107,7 +107,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.include_router(refractor.router)
app.include_router(season_stats.router)

View File

@ -4,9 +4,19 @@ import logging
import pydantic
from pandas import DataFrame
from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist
from ..db_engine import (
db,
Card,
model_to_dict,
Team,
Player,
Pack,
Paperdex,
CARDSETS,
DoesNotExist,
)
from ..dependencies import oauth2_scheme, valid_token
from ..services.evolution_init import _determine_card_type, initialize_card_evolution
from ..services.refractor_init import _determine_card_type, initialize_card_refractor
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
@ -47,19 +57,25 @@ async def get_cards(
try:
this_team = Team.get_by_id(team_id)
except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No team found with id {team_id}')
raise HTTPException(
status_code=404, detail=f"No team found with id {team_id}"
)
all_cards = all_cards.where(Card.team == this_team)
if player_id is not None:
try:
this_player = Player.get_by_id(player_id)
except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No player found with id {player_id}')
raise HTTPException(
status_code=404, detail=f"No player found with id {player_id}"
)
all_cards = all_cards.where(Card.player == this_player)
if pack_id is not None:
try:
this_pack = Pack.get_by_id(pack_id)
except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No pack found with id {pack_id}')
raise HTTPException(
status_code=404, detail=f"No pack found with id {pack_id}"
)
all_cards = all_cards.where(Card.pack == this_pack)
if value is not None:
all_cards = all_cards.where(Card.value == value)
@ -125,7 +141,6 @@ async def get_cards(
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}")
@ -147,7 +162,7 @@ async def v1_cards_get_one(card_id, csv: Optional[bool] = False):
try:
this_card = Card.get_by_id(card_id)
except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No card found with id {card_id}')
raise HTTPException(status_code=404, detail=f"No card found with id {card_id}")
if csv:
data_list = [
@ -207,15 +222,15 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
cost_query.execute()
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
# WP-10: initialize evolution state for each new card (fire-and-forget)
# WP-10: initialize refractor state for each new card (fire-and-forget)
for x in cards.cards:
try:
this_player = Player.get_by_id(x.player_id)
card_type = _determine_card_type(this_player)
initialize_card_evolution(x.player_id, x.team_id, card_type)
initialize_card_refractor(x.player_id, x.team_id, card_type)
except Exception:
logging.exception(
"evolution hook: unexpected error for player_id=%s team_id=%s",
"refractor hook: unexpected error for player_id=%s team_id=%s",
x.player_id,
x.team_id,
)
@ -319,8 +334,8 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
try:
this_team = Team.get_by_id(team_id)
except DoesNotExist:
logging.error(f'/cards/wipe-team/{team_id} - could not find team')
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')
logging.error(f"/cards/wipe-team/{team_id} - could not find team")
raise HTTPException(status_code=404, detail=f"Team {team_id} not found")
t_query = Card.update(team=None).where(Card.team == this_team).execute()
return f"Wiped {t_query} cards"
@ -348,7 +363,7 @@ async def v1_cards_patch(
try:
this_card = Card.get_by_id(card_id)
except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No card found with id {card_id}')
raise HTTPException(status_code=404, detail=f"No card found with id {card_id}")
if player_id is not None:
this_card.player_id = player_id
@ -391,7 +406,7 @@ async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)):
try:
this_card = Card.get_by_id(card_id)
except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No cards found with id {card_id}')
raise HTTPException(status_code=404, detail=f"No cards found with id {card_id}")
count = this_card.delete_instance()

View File

@ -7,10 +7,10 @@ from ..dependencies import oauth2_scheme, valid_token
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"])
router = APIRouter(prefix="/api/v2/refractor", tags=["refractor"])
# Tier -> threshold attribute name. Index = current_tier; value is the
# attribute on EvolutionTrack whose value is the *next* threshold to reach.
# attribute on RefractorTrack whose value is the *next* threshold to reach.
# Tier 4 is fully evolved so there is no next threshold (None sentinel).
_NEXT_THRESHOLD_ATTR = {
0: "t1_threshold",
@ -22,7 +22,7 @@ _NEXT_THRESHOLD_ATTR = {
def _build_card_state_response(state) -> dict:
"""Serialise an EvolutionCardState into the standard API response shape.
"""Serialise a RefractorCardState into the standard API response shape.
Produces a flat dict with player_id and team_id as plain integers,
a nested 'track' dict with all threshold fields, and a computed
@ -63,11 +63,11 @@ async def list_tracks(
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionTrack
from ..db_engine import RefractorTrack
query = EvolutionTrack.select()
query = RefractorTrack.select()
if card_type is not None:
query = query.where(EvolutionTrack.card_type == card_type)
query = query.where(RefractorTrack.card_type == card_type)
items = [model_to_dict(t, recurse=False) for t in query]
return {"count": len(items), "items": items}
@ -79,10 +79,10 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionTrack
from ..db_engine import RefractorTrack
try:
track = EvolutionTrack.get_by_id(track_id)
track = RefractorTrack.get_by_id(track_id)
except Exception:
raise HTTPException(status_code=404, detail=f"Track {track_id} not found")
@ -91,22 +91,22 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
@router.get("/cards/{card_id}")
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
"""Return the EvolutionCardState for a card identified by its Card.id.
"""Return the RefractorCardState for a card identified by its Card.id.
Resolves card_id -> (player_id, team_id) via the Card table, then looks
up the matching EvolutionCardState row. Because duplicate cards for the
up the matching RefractorCardState row. Because duplicate cards for the
same player+team share one state row (unique-(player,team) constraint),
any card_id belonging to that player on that team returns the same state.
Returns 404 when:
- The card_id does not exist in the Card table.
- The card exists but has no corresponding EvolutionCardState yet.
- The card exists but has no corresponding RefractorCardState yet.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import Card, EvolutionCardState, EvolutionTrack, DoesNotExist
from ..db_engine import Card, RefractorCardState, RefractorTrack, DoesNotExist
# Resolve card_id to player+team
try:
@ -114,22 +114,22 @@ async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
except DoesNotExist:
raise HTTPException(status_code=404, detail=f"Card {card_id} not found")
# Look up the evolution state for this (player, team) pair, joining the
# Look up the refractor state for this (player, team) pair, joining the
# track so a single query resolves both rows.
try:
state = (
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
.join(EvolutionTrack)
RefractorCardState.select(RefractorCardState, RefractorTrack)
.join(RefractorTrack)
.where(
(EvolutionCardState.player == card.player_id)
& (EvolutionCardState.team == card.team_id)
(RefractorCardState.player == card.player_id)
& (RefractorCardState.team == card.team_id)
)
.get()
)
except DoesNotExist:
raise HTTPException(
status_code=404,
detail=f"No evolution state for card {card_id}",
detail=f"No refractor state for card {card_id}",
)
return _build_card_state_response(state)
@ -137,9 +137,9 @@ async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
@router.post("/cards/{card_id}/evaluate")
async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)):
"""Force-recalculate evolution state for a card from career stats.
"""Force-recalculate refractor state for a card from career stats.
Resolves card_id to (player_id, team_id), then recomputes the evolution
Resolves card_id to (player_id, team_id), then recomputes the refractor
tier from all player_season_stats rows for that pair. Idempotent.
"""
if not valid_token(token):
@ -147,7 +147,7 @@ async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)):
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import Card
from ..services.evolution_evaluator import evaluate_card as _evaluate
from ..services.refractor_evaluator import evaluate_card as _evaluate
try:
card = Card.get_by_id(card_id)
@ -164,10 +164,10 @@ async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)):
@router.post("/evaluate-game/{game_id}")
async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
"""Evaluate evolution state for all players who appeared in a game.
"""Evaluate refractor state for all players who appeared in a game.
Finds all unique (player_id, team_id) pairs from the game's StratPlay rows,
then for each pair that has an EvolutionCardState, re-computes the evolution
then for each pair that has a RefractorCardState, re-computes the refractor
tier. Pairs without a state row are silently skipped. Per-player errors are
logged but do not abort the batch.
"""
@ -175,8 +175,8 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionCardState, EvolutionTrack, Player, StratPlay
from ..services.evolution_evaluator import evaluate_card
from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay
from ..services.refractor_evaluator import evaluate_card
plays = list(StratPlay.select().where(StratPlay.game == game_id))
@ -192,9 +192,9 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
for player_id, team_id in pairs:
try:
state = EvolutionCardState.get_or_none(
(EvolutionCardState.player_id == player_id)
& (EvolutionCardState.team_id == team_id)
state = RefractorCardState.get_or_none(
(RefractorCardState.player_id == player_id)
& (RefractorCardState.team_id == team_id)
)
if state is None:
continue
@ -225,7 +225,7 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
)
except Exception as exc:
logger.warning(
f"Evolution eval failed for player={player_id} team={team_id}: {exc}"
f"Refractor eval failed for player={player_id} team={team_id}: {exc}"
)
return {"evaluated": evaluated, "tier_ups": tier_ups}

View File

@ -1049,7 +1049,6 @@ async def team_buy_players(team_id: int, ids: str, ts: str):
detail=f"You are not authorized to buy {this_team.abbrev} cards. This event has been logged.",
)
all_ids = ids.split(",")
conf_message = ""
total_cost = 0
@ -1531,8 +1530,8 @@ async def delete_team(team_id, token: str = Depends(oauth2_scheme)):
raise HTTPException(status_code=500, detail=f"Team {team_id} was not deleted")
@router.get("/{team_id}/evolutions")
async def list_team_evolutions(
@router.get("/{team_id}/refractors")
async def list_team_refractors(
team_id: int,
card_type: Optional[str] = Query(default=None),
tier: Optional[int] = Query(default=None),
@ -1540,9 +1539,9 @@ async def list_team_evolutions(
per_page: int = Query(default=10, ge=1, le=100),
token: str = Depends(oauth2_scheme),
):
"""List all EvolutionCardState rows for a team, with optional filters.
"""List all RefractorCardState rows for a team, with optional filters.
Joins EvolutionCardState to EvolutionTrack so that card_type filtering
Joins RefractorCardState to RefractorTrack so that card_type filtering
works without a second query. Results are paginated via page/per_page
(1-indexed pages); items are ordered by player_id for stable ordering.
@ -1555,27 +1554,27 @@ async def list_team_evolutions(
Response shape:
{"count": N, "items": [card_state_with_threshold_context, ...]}
Each item in 'items' has the same shape as GET /evolution/cards/{card_id}.
Each item in 'items' has the same shape as GET /refractor/cards/{card_id}.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionCardState, EvolutionTrack
from ..routers_v2.evolution import _build_card_state_response
from ..db_engine import RefractorCardState, RefractorTrack
from ..routers_v2.refractor import _build_card_state_response
query = (
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
.join(EvolutionTrack)
.where(EvolutionCardState.team == team_id)
.order_by(EvolutionCardState.player_id)
RefractorCardState.select(RefractorCardState, RefractorTrack)
.join(RefractorTrack)
.where(RefractorCardState.team == team_id)
.order_by(RefractorCardState.player_id)
)
if card_type is not None:
query = query.where(EvolutionTrack.card_type == card_type)
query = query.where(RefractorTrack.card_type == card_type)
if tier is not None:
query = query.where(EvolutionCardState.current_tier == tier)
query = query.where(RefractorCardState.current_tier == tier)
total = query.count()
offset = (page - 1) * per_page

View File

@ -1,36 +1,36 @@
"""Seed script for EvolutionTrack records.
"""Seed script for RefractorTrack records.
Loads track definitions from evolution_tracks.json and upserts them into the
Loads track definitions from refractor_tracks.json and upserts them into the
database using get_or_create keyed on name. Existing tracks have their
thresholds and formula updated to match the JSON in case values have changed.
Can be run standalone:
python -m app.seed.evolution_tracks
python -m app.seed.refractor_tracks
"""
import json
import logging
from pathlib import Path
from app.db_engine import EvolutionTrack
from app.db_engine import RefractorTrack
logger = logging.getLogger(__name__)
_JSON_PATH = Path(__file__).parent / "evolution_tracks.json"
_JSON_PATH = Path(__file__).parent / "refractor_tracks.json"
def seed_evolution_tracks() -> list[EvolutionTrack]:
"""Upsert evolution tracks from JSON seed data.
def seed_refractor_tracks() -> list[RefractorTrack]:
"""Upsert refractor tracks from JSON seed data.
Returns a list of EvolutionTrack instances that were created or updated.
Returns a list of RefractorTrack instances that were created or updated.
"""
raw = _JSON_PATH.read_text(encoding="utf-8")
track_defs = json.loads(raw)
results: list[EvolutionTrack] = []
results: list[RefractorTrack] = []
for defn in track_defs:
track, created = EvolutionTrack.get_or_create(
track, created = RefractorTrack.get_or_create(
name=defn["name"],
defaults={
"card_type": defn["card_type"],
@ -61,6 +61,6 @@ def seed_evolution_tracks() -> list[EvolutionTrack]:
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
logger.info("Seeding evolution tracks...")
tracks = seed_evolution_tracks()
logger.info("Seeding refractor tracks...")
tracks = seed_refractor_tracks()
logger.info("Done. %d track(s) processed.", len(tracks))

View File

@ -1,6 +1,6 @@
"""Formula engine for evolution value computation (WP-09).
"""Formula engine for refractor value computation (WP-09).
Three pure functions that compute a numeric evolution value from career stats,
Three pure functions that compute a numeric refractor value from career stats,
plus helpers for formula dispatch and tier classification.
Stats attributes expected by each formula:
@ -79,7 +79,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 refractor tier (0-4) for a computed value against a track.
Tier boundaries are inclusive on the lower end:
T0: value < t1

View File

@ -1,6 +1,6 @@
"""Evolution evaluator service (WP-08).
"""Refractor evaluator service (WP-08).
Force-recalculates a card's evolution state from career totals.
Force-recalculates a card's refractor state from career totals.
evaluate_card() is the main entry point:
1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id)
@ -14,7 +14,7 @@ evaluate_card() is the main entry point:
Idempotent: calling multiple times with the same data produces the same result.
Depends on WP-05 (EvolutionCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
and WP-09 (formula engine). Models and formula functions are imported lazily so
this module can be imported before those PRs merge.
"""
@ -52,11 +52,11 @@ def evaluate_card(
_compute_value_fn=None,
_tier_from_value_fn=None,
) -> dict:
"""Force-recalculate a card's evolution tier from career stats.
"""Force-recalculate a card's refractor tier from career stats.
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
card_type) for (player_id, team_id) across all seasons, then delegates
formula computation and tier classification to the formula engine. The result is written back to evolution_card_state and
formula computation and tier classification to the formula engine. The result is written back to refractor_card_state and
returned as a dict.
current_tier never decreases (no regression):
@ -67,7 +67,7 @@ def evaluate_card(
team_id: Team primary key.
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats
(used in tests to inject a stub model with all stat fields).
_state_model: Override for EvolutionCardState (used in tests to avoid
_state_model: Override for RefractorCardState (used in tests to avoid
importing from db_engine before WP-05 merges).
_compute_value_fn: Override for formula_engine.compute_value_for_track
(used in tests to avoid importing formula_engine before WP-09 merges).
@ -79,10 +79,10 @@ def evaluate_card(
last_evaluated_at (ISO-8601 string).
Raises:
ValueError: If no evolution_card_state row exists for (player_id, team_id).
ValueError: If no refractor_card_state row exists for (player_id, team_id).
"""
if _state_model is None:
from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415
from app.db_engine import RefractorCardState as _state_model # noqa: PLC0415
if _compute_value_fn is None or _tier_from_value_fn is None:
from app.services.formula_engine import ( # noqa: PLC0415
@ -101,7 +101,7 @@ def evaluate_card(
)
if card_state is None:
raise ValueError(
f"No evolution_card_state for player_id={player_id} team_id={team_id}"
f"No refractor_card_state for player_id={player_id} team_id={team_id}"
)
# 2. Load career totals from the appropriate season stats table
@ -178,7 +178,7 @@ def evaluate_card(
card_state.save()
logging.debug(
"evolution_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s",
"refractor_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s",
player_id,
team_id,
value,

View File

@ -1,10 +1,10 @@
"""
WP-10: Pack opening hook evolution_card_state initialization.
WP-10: Pack opening hook refractor_card_state initialization.
Public API
----------
initialize_card_evolution(player_id, team_id, card_type)
Get-or-create an EvolutionCardState for the (player_id, team_id) pair.
initialize_card_refractor(player_id, team_id, card_type)
Get-or-create a RefractorCardState for the (player_id, team_id) pair.
Returns the state instance on success, or None if initialization fails
(missing track, integrity error, etc.). Never raises.
@ -16,23 +16,23 @@ Design notes
------------
- The function is intentionally fire-and-forget from the caller's perspective.
All exceptions are caught and logged; pack opening is never blocked.
- No EvolutionProgress rows are created here. Progress accumulation is a
- No RefractorProgress rows are created here. Progress accumulation is a
separate concern handled by the stats-update pipeline (WP-07/WP-08).
- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern);
we do NOT replicate that exclusion here all teams get an evolution state
we do NOT replicate that exclusion here all teams get a refractor state
so that future rule changes don't require back-filling.
"""
import logging
from typing import Optional
from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack
from app.db_engine import DoesNotExist, RefractorCardState, RefractorTrack
logger = logging.getLogger(__name__)
def _determine_card_type(player) -> str:
"""Map a player's primary position to an evolution card_type string.
"""Map a player's primary position to a refractor card_type string.
Rules (from WP-10 spec):
- pos_1 contains 'SP' -> 'sp'
@ -53,34 +53,34 @@ def _determine_card_type(player) -> str:
return "batter"
def initialize_card_evolution(
def initialize_card_refractor(
player_id: int,
team_id: int,
card_type: str,
) -> Optional[EvolutionCardState]:
"""Get-or-create an EvolutionCardState for a newly acquired card.
) -> Optional[RefractorCardState]:
"""Get-or-create a RefractorCardState for a newly acquired card.
Called by the cards POST endpoint after each card is inserted. The
function is idempotent: if a state row already exists for the
(player_id, team_id) pair it is returned unchanged existing
evolution progress is never reset.
refractor progress is never reset.
Args:
player_id: Primary key of the Player row (Player.player_id).
team_id: Primary key of the Team row (Team.id).
card_type: One of 'batter', 'sp', 'rp'. Determines which
EvolutionTrack is assigned to the new state.
RefractorTrack is assigned to the new state.
Returns:
The existing or newly created EvolutionCardState instance, or
The existing or newly created RefractorCardState instance, or
None if initialization could not complete (missing track seed
data, unexpected DB error, etc.).
"""
try:
track = EvolutionTrack.get(EvolutionTrack.card_type == card_type)
track = RefractorTrack.get(RefractorTrack.card_type == card_type)
except DoesNotExist:
logger.warning(
"evolution_init: no EvolutionTrack found for card_type=%r "
"refractor_init: no RefractorTrack found for card_type=%r "
"(player_id=%s, team_id=%s) — skipping state creation",
card_type,
player_id,
@ -89,7 +89,7 @@ def initialize_card_evolution(
return None
except Exception:
logger.exception(
"evolution_init: unexpected error fetching track "
"refractor_init: unexpected error fetching track "
"(card_type=%r, player_id=%s, team_id=%s)",
card_type,
player_id,
@ -98,7 +98,7 @@ def initialize_card_evolution(
return None
try:
state, created = EvolutionCardState.get_or_create(
state, created = RefractorCardState.get_or_create(
player_id=player_id,
team_id=team_id,
defaults={
@ -110,7 +110,7 @@ def initialize_card_evolution(
)
if created:
logger.debug(
"evolution_init: created EvolutionCardState id=%s "
"refractor_init: created RefractorCardState id=%s "
"(player_id=%s, team_id=%s, card_type=%r)",
state.id,
player_id,
@ -119,7 +119,7 @@ def initialize_card_evolution(
)
else:
logger.debug(
"evolution_init: state already exists id=%s "
"refractor_init: state already exists id=%s "
"(player_id=%s, team_id=%s) — no-op",
state.id,
player_id,
@ -129,7 +129,7 @@ def initialize_card_evolution(
except Exception:
logger.exception(
"evolution_init: failed to get_or_create state "
"refractor_init: failed to get_or_create state "
"(player_id=%s, team_id=%s, card_type=%r)",
player_id,
team_id,

View File

@ -0,0 +1,19 @@
-- Migration: Rename evolution tables to refractor tables
-- Date: 2026-03-23
--
-- Renames all four evolution system tables to the refractor naming convention.
-- This migration corresponds to the application-level rename from
-- EvolutionTrack/EvolutionCardState/EvolutionTierBoost/EvolutionCosmetic
-- to RefractorTrack/RefractorCardState/RefractorTierBoost/RefractorCosmetic.
--
-- The table renames are performed in order that respects foreign key
-- dependencies (referenced tables first, then referencing tables).
ALTER TABLE evolution_track RENAME TO refractor_track;
ALTER TABLE evolution_card_state RENAME TO refractor_card_state;
ALTER TABLE evolution_tier_boost RENAME TO refractor_tier_boost;
ALTER TABLE evolution_cosmetic RENAME TO refractor_cosmetic;
-- Rename indexes to match new table names
ALTER INDEX IF EXISTS evolution_card_state_player_team_uniq RENAME TO refractor_card_state_player_team_uniq;
ALTER INDEX IF EXISTS evolution_tier_boost_track_tier_type_target_uniq RENAME TO refractor_tier_boost_track_tier_type_target_uniq;

View File

@ -44,10 +44,10 @@ from app.db_engine import (
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
EvolutionTrack,
EvolutionCardState,
EvolutionTierBoost,
EvolutionCosmetic,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
ScoutOpportunity,
ScoutClaim,
)
@ -76,10 +76,10 @@ _TEST_MODELS = [
ProcessedGame,
ScoutOpportunity,
ScoutClaim,
EvolutionTrack,
EvolutionCardState,
EvolutionTierBoost,
EvolutionCosmetic,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
@ -164,8 +164,8 @@ def team():
@pytest.fixture
def track():
"""A minimal EvolutionTrack for batter cards."""
return EvolutionTrack.create(
"""A minimal RefractorTrack for batter cards."""
return RefractorTrack.create(
name="Batter Track",
card_type="batter",
formula="pa + tb * 2",
@ -177,7 +177,7 @@ def track():
# ---------------------------------------------------------------------------
# PostgreSQL integration fixture (used by test_evolution_*_api.py)
# PostgreSQL integration fixture (used by test_refractor_*_api.py)
# ---------------------------------------------------------------------------

View File

@ -3,7 +3,7 @@
Unit tests only no database required. Stats inputs are simple namespace
objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose.
Tier thresholds used (from evolution_tracks.json seed data):
Tier thresholds used (from refractor_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

View File

@ -2,7 +2,7 @@
Tests cover both post-game callback endpoints:
POST /api/v2/season-stats/update-game/{game_id}
POST /api/v2/evolution/evaluate-game/{game_id}
POST /api/v2/refractor/evaluate-game/{game_id}
All tests run against a named shared-memory SQLite database so that Peewee
model queries inside the route handlers (which execute in the TestClient's
@ -34,7 +34,7 @@ Test matrix:
test_evaluate_game_tier_ups_in_response
Tier-up appears in tier_ups list with correct fields.
test_evaluate_game_skips_players_without_state
Players in game but without EvolutionCardState are silently skipped.
Players in game but without RefractorCardState are silently skipped.
test_auth_required_update_game
Missing bearer token returns 401 on update-game.
test_auth_required_evaluate_game
@ -55,10 +55,10 @@ from peewee import SqliteDatabase
from app.db_engine import (
Cardset,
EvolutionCardState,
EvolutionCosmetic,
EvolutionTierBoost,
EvolutionTrack,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
MlbPlayer,
Pack,
PackType,
@ -111,10 +111,10 @@ _WP13_MODELS = [
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
EvolutionTrack,
EvolutionCardState,
EvolutionTierBoost,
EvolutionCosmetic,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
# Patch the service-layer 'db' reference to use our shared test database so
@ -165,7 +165,7 @@ def _build_test_app() -> FastAPI:
connection even though it runs in a different thread from the fixture.
"""
from app.routers_v2.season_stats import router as ss_router
from app.routers_v2.evolution import router as evo_router
from app.routers_v2.refractor import router as evo_router
test_app = FastAPI()
@ -294,8 +294,8 @@ def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **sta
def _make_track(
name: str = "WP13 Batter Track", card_type: str = "batter"
) -> EvolutionTrack:
track, _ = EvolutionTrack.get_or_create(
) -> RefractorTrack:
track, _ = RefractorTrack.get_or_create(
name=name,
defaults=dict(
card_type=card_type,
@ -311,8 +311,8 @@ def _make_track(
def _make_state(
player, team, track, current_tier=0, current_value=0.0
) -> EvolutionCardState:
return EvolutionCardState.create(
) -> RefractorCardState:
return RefractorCardState.create(
player=player,
team=team,
track=track,
@ -428,14 +428,14 @@ def test_update_game_idempotent(client):
# ---------------------------------------------------------------------------
# Tests: POST /api/v2/evolution/evaluate-game/{game_id}
# Tests: POST /api/v2/refractor/evaluate-game/{game_id}
# ---------------------------------------------------------------------------
def test_evaluate_game_increases_current_value(client):
"""After update-game, evaluate-game raises the card's current_value above 0.
What: Batter with an EvolutionCardState gets 3 hits (pa=3, hit=3) from a
What: Batter with a RefractorCardState gets 3 hits (pa=3, hit=3) from a
game. update-game writes those stats; evaluate-game then recomputes the
value. current_value in the DB must be > 0 after the evaluate call.
@ -458,12 +458,12 @@ def test_evaluate_game_increases_current_value(client):
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
state = EvolutionCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a)
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_value > 0
@ -501,12 +501,12 @@ def test_evaluate_game_tier_advancement(client):
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
updated_state = EvolutionCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a)
updated_state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert updated_state.current_tier >= 1
@ -535,15 +535,15 @@ def test_evaluate_game_no_tier_advancement(client):
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["tier_ups"] == []
state = EvolutionCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a)
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 0
@ -577,7 +577,7 @@ def test_evaluate_game_tier_ups_in_response(client):
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
@ -596,7 +596,7 @@ def test_evaluate_game_tier_ups_in_response(client):
def test_evaluate_game_skips_players_without_state(client):
"""Players in a game without an EvolutionCardState are silently skipped.
"""Players in a game without a RefractorCardState are silently skipped.
What: A game has two players: one with a card state and one without.
After evaluate-game, evaluated should be 1 (only the player with state)
@ -613,7 +613,7 @@ def test_evaluate_game_skips_players_without_state(client):
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Skip Track")
# Only batter_with_state gets an EvolutionCardState
# Only batter_with_state gets a RefractorCardState
_make_state(batter_with_state, team_a, track)
_make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
@ -621,7 +621,7 @@ def test_evaluate_game_skips_players_without_state(client):
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
@ -663,5 +663,5 @@ def test_auth_required_evaluate_game(client):
team_b = _make_team("WB2", gmid=20092)
game = _make_game(team_a, team_b)
resp = client.post(f"/api/v2/evolution/evaluate-game/{game.id}")
resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}")
assert resp.status_code == 401

View File

@ -1,7 +1,7 @@
"""Tests for the evolution evaluator service (WP-08).
"""Tests for the refractor evaluator service (WP-08).
Unit tests verify tier assignment, advancement, partial progress, idempotency,
full evolution, and no-regression behaviour without touching any database,
full refractor tier, and no-regression behaviour without touching any database,
using stub Peewee models bound to an in-memory SQLite database.
The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported
@ -33,7 +33,7 @@ from peewee import (
SqliteDatabase,
)
from app.services.evolution_evaluator import evaluate_card
from app.services.refractor_evaluator import evaluate_card
# ---------------------------------------------------------------------------
# Stub models — mirror WP-01/WP-04/WP-07 schema without importing db_engine
@ -43,7 +43,7 @@ _test_db = SqliteDatabase(":memory:")
class TrackStub(Model):
"""Minimal EvolutionTrack stub for evaluator tests."""
"""Minimal RefractorTrack stub for evaluator tests."""
card_type = CharField(unique=True)
t1_threshold = IntegerField()
@ -53,11 +53,11 @@ class TrackStub(Model):
class Meta:
database = _test_db
table_name = "evolution_track"
table_name = "refractor_track"
class CardStateStub(Model):
"""Minimal EvolutionCardState stub for evaluator tests."""
"""Minimal RefractorCardState stub for evaluator tests."""
player_id = IntegerField()
team_id = IntegerField()
@ -69,7 +69,7 @@ class CardStateStub(Model):
class Meta:
database = _test_db
table_name = "evolution_card_state"
table_name = "refractor_card_state"
indexes = ((("player_id", "team_id"), True),)
@ -331,7 +331,7 @@ class TestMissingState:
def test_missing_state_raises(self, batter_track):
"""evaluate_card raises ValueError when no state row exists."""
# No card state created
with pytest.raises(ValueError, match="No evolution_card_state"):
with pytest.raises(ValueError, match="No refractor_card_state"):
_eval(99, 99)

View File

@ -1,8 +1,8 @@
"""
Tests for WP-10: evolution_card_state initialization on pack opening.
Tests for WP-10: refractor_card_state initialization on pack opening.
Covers `app/services/evolution_init.py` the `initialize_card_evolution`
function that creates an EvolutionCardState row when a card is first acquired.
Covers `app/services/refractor_init.py` the `initialize_card_refractor`
function that creates an RefractorCardState row when a card is first acquired.
Test strategy:
- Unit tests for `_determine_card_type` cover all three branches (batter,
@ -18,7 +18,7 @@ Why we test idempotency:
Why we test cross-player isolation:
Two different players with the same team must each get their own
EvolutionCardState row. A bug that checked only team_id would share state
RefractorCardState row. A bug that checked only team_id would share state
across players, so we assert that state.player_id matches.
"""
@ -26,11 +26,11 @@ import pytest
from app.db_engine import (
Cardset,
EvolutionCardState,
EvolutionTrack,
RefractorCardState,
RefractorTrack,
Player,
)
from app.services.evolution_init import _determine_card_type, initialize_card_evolution
from app.services.refractor_init import _determine_card_type, initialize_card_refractor
# ---------------------------------------------------------------------------
@ -74,13 +74,13 @@ def _make_player(rarity, pos_1: str) -> Player:
)
def _make_track(card_type: str) -> EvolutionTrack:
"""Create an EvolutionTrack for the given card_type.
def _make_track(card_type: str) -> RefractorTrack:
"""Create an RefractorTrack for the given card_type.
Thresholds are kept small and arbitrary; the unit under test only
cares about card_type when selecting the track.
"""
return EvolutionTrack.create(
return RefractorTrack.create(
name=f"Track-{card_type}",
card_type=card_type,
formula="pa",
@ -116,14 +116,14 @@ class TestDetermineCardType:
"""pos_1 == 'RP' maps to card_type 'rp'.
Relief pitchers carry the 'RP' position flag and must follow a
separate evolution track with lower thresholds.
separate refractor track with lower thresholds.
"""
assert _determine_card_type(_FakePlayer("RP")) == "rp"
def test_closer_pitcher(self):
"""pos_1 == 'CP' maps to card_type 'rp'.
Closers share the RP evolution track; the spec explicitly lists 'CP'
Closers share the RP refractor track; the spec explicitly lists 'CP'
as an rp-track position.
"""
assert _determine_card_type(_FakePlayer("CP")) == "rp"
@ -154,12 +154,12 @@ class TestDetermineCardType:
# ---------------------------------------------------------------------------
# Integration tests — initialize_card_evolution
# Integration tests — initialize_card_refractor
# ---------------------------------------------------------------------------
class TestInitializeCardEvolution:
"""Integration tests for initialize_card_evolution against in-memory SQLite.
"""Integration tests for initialize_card_refractor against in-memory SQLite.
Each test relies on the conftest autouse fixture to get a clean database.
We create tracks for all three card types so the function can always find
@ -168,9 +168,9 @@ class TestInitializeCardEvolution:
@pytest.fixture(autouse=True)
def seed_tracks(self):
"""Create one EvolutionTrack per card_type before each test.
"""Create one RefractorTrack per card_type before each test.
initialize_card_evolution does a DB lookup for a track matching the
initialize_card_refractor does a DB lookup for a track matching the
card_type. If no track exists the function must not crash (it should
log and return None), but having tracks present lets us verify the
happy path for all three types without repeating setup in every test.
@ -180,7 +180,7 @@ class TestInitializeCardEvolution:
self.rp_track = _make_track("rp")
def test_first_card_creates_state(self, rarity, team):
"""First acquisition creates an EvolutionCardState with zeroed values.
"""First acquisition creates an RefractorCardState with zeroed values.
Acceptance criteria from WP-10:
- current_tier == 0
@ -189,7 +189,7 @@ class TestInitializeCardEvolution:
- track matches the player's card_type (batter here)
"""
player = _make_player(rarity, "2B")
state = initialize_card_evolution(player.player_id, team.id, "batter")
state = initialize_card_refractor(player.player_id, team.id, "batter")
assert state is not None
assert state.player_id == player.player_id
@ -208,7 +208,7 @@ class TestInitializeCardEvolution:
"""
player = _make_player(rarity, "SS")
# First call creates the state
state1 = initialize_card_evolution(player.player_id, team.id, "batter")
state1 = initialize_card_refractor(player.player_id, team.id, "batter")
assert state1 is not None
# Simulate partial evolution progress
@ -217,22 +217,22 @@ class TestInitializeCardEvolution:
state1.save()
# Second call (duplicate card) must not reset progress
state2 = initialize_card_evolution(player.player_id, team.id, "batter")
state2 = initialize_card_refractor(player.player_id, team.id, "batter")
assert state2 is not None
# Exactly one row in the database
count = (
EvolutionCardState.select()
RefractorCardState.select()
.where(
EvolutionCardState.player == player,
EvolutionCardState.team == team,
RefractorCardState.player == player,
RefractorCardState.team == team,
)
.count()
)
assert count == 1
# Progress was NOT reset
refreshed = EvolutionCardState.get_by_id(state1.id)
refreshed = RefractorCardState.get_by_id(state1.id)
assert refreshed.current_tier == 2
assert refreshed.current_value == 250.0
@ -246,8 +246,8 @@ class TestInitializeCardEvolution:
player_a = _make_player(rarity, "LF")
player_b = _make_player(rarity, "RF")
state_a = initialize_card_evolution(player_a.player_id, team.id, "batter")
state_b = initialize_card_evolution(player_b.player_id, team.id, "batter")
state_a = initialize_card_refractor(player_a.player_id, team.id, "batter")
state_b = initialize_card_refractor(player_b.player_id, team.id, "batter")
assert state_a is not None
assert state_b is not None
@ -256,7 +256,7 @@ class TestInitializeCardEvolution:
assert state_b.player_id == player_b.player_id
def test_sp_card_gets_sp_track(self, rarity, team):
"""A starting pitcher is assigned the 'sp' EvolutionTrack.
"""A starting pitcher is assigned the 'sp' RefractorTrack.
Track selection is driven by card_type, which in turn comes from
pos_1. This test passes card_type='sp' explicitly (mirroring the
@ -264,15 +264,15 @@ class TestInitializeCardEvolution:
state links to the sp track, not the batter track.
"""
player = _make_player(rarity, "SP")
state = initialize_card_evolution(player.player_id, team.id, "sp")
state = initialize_card_refractor(player.player_id, team.id, "sp")
assert state is not None
assert state.track_id == self.sp_track.id
def test_rp_card_gets_rp_track(self, rarity, team):
"""A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack."""
"""A relief pitcher (RP or CP) is assigned the 'rp' RefractorTrack."""
player = _make_player(rarity, "RP")
state = initialize_card_evolution(player.player_id, team.id, "rp")
state = initialize_card_refractor(player.player_id, team.id, "rp")
assert state is not None
assert state.track_id == self.rp_track.id
@ -291,7 +291,7 @@ class TestInitializeCardEvolution:
# Delete the sp track to simulate missing seed data
self.sp_track.delete_instance()
result = initialize_card_evolution(player.player_id, team.id, "sp")
result = initialize_card_refractor(player.player_id, team.id, "sp")
assert result is None
def test_card_type_from_pos1_batter(self, rarity, team):
@ -302,7 +302,7 @@ class TestInitializeCardEvolution:
"""
player = _make_player(rarity, "3B")
card_type = _determine_card_type(player)
state = initialize_card_evolution(player.player_id, team.id, card_type)
state = initialize_card_refractor(player.player_id, team.id, card_type)
assert state is not None
assert state.track_id == self.batter_track.id
@ -311,7 +311,7 @@ class TestInitializeCardEvolution:
"""_determine_card_type is wired correctly for a starting pitcher."""
player = _make_player(rarity, "SP")
card_type = _determine_card_type(player)
state = initialize_card_evolution(player.player_id, team.id, card_type)
state = initialize_card_refractor(player.player_id, team.id, card_type)
assert state is not None
assert state.track_id == self.sp_track.id
@ -320,7 +320,7 @@ class TestInitializeCardEvolution:
"""_determine_card_type correctly routes CP to the rp track."""
player = _make_player(rarity, "CP")
card_type = _determine_card_type(player)
state = initialize_card_evolution(player.player_id, team.id, card_type)
state = initialize_card_refractor(player.player_id, team.id, card_type)
assert state is not None
assert state.track_id == self.rp_track.id

View File

@ -1,12 +1,12 @@
"""
Tests for evolution-related models and BattingSeasonStats.
Tests for refractor-related models and BattingSeasonStats.
Covers WP-01 acceptance criteria:
- EvolutionTrack: CRUD and unique-name constraint
- EvolutionCardState: CRUD, defaults, unique-(player,team) constraint,
and FK resolution back to EvolutionTrack
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
- EvolutionCosmetic: CRUD and unique-name constraint
- RefractorTrack: CRUD and unique-name constraint
- RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
and FK resolution back to RefractorTrack
- RefractorTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
- RefractorCosmetic: CRUD and unique-name constraint
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
and in-place stat accumulation
@ -21,21 +21,21 @@ from playhouse.shortcuts import model_to_dict
from app.db_engine import (
BattingSeasonStats,
EvolutionCardState,
EvolutionCosmetic,
EvolutionTierBoost,
EvolutionTrack,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
)
# ---------------------------------------------------------------------------
# EvolutionTrack
# RefractorTrack
# ---------------------------------------------------------------------------
class TestEvolutionTrack:
"""Tests for the EvolutionTrack model.
class TestRefractorTrack:
"""Tests for the RefractorTrack model.
EvolutionTrack defines a named progression path (formula +
RefractorTrack defines a named progression path (formula +
tier thresholds) for a card type. The name column carries a
UNIQUE constraint so that accidental duplicates are caught at
the database level.
@ -60,12 +60,12 @@ class TestEvolutionTrack:
def test_track_unique_name(self, track):
"""Inserting a second track with the same name raises IntegrityError.
The UNIQUE constraint on EvolutionTrack.name must prevent two
The UNIQUE constraint on RefractorTrack.name must prevent two
tracks from sharing the same identifier, as the name is used as
a human-readable key throughout the evolution system.
"""
with pytest.raises(IntegrityError):
EvolutionTrack.create(
RefractorTrack.create(
name="Batter Track", # duplicate
card_type="sp",
formula="outs * 3",
@ -77,15 +77,15 @@ class TestEvolutionTrack:
# ---------------------------------------------------------------------------
# EvolutionCardState
# RefractorCardState
# ---------------------------------------------------------------------------
class TestEvolutionCardState:
"""Tests for EvolutionCardState, which tracks per-player evolution progress.
class TestRefractorCardState:
"""Tests for RefractorCardState, which tracks per-player refractor progress.
Each row represents one card (player) owned by one team, linked to a
specific EvolutionTrack. The model records the current tier (0-4),
specific RefractorTrack. The model records the current tier (0-4),
accumulated progress value, and whether the card is fully evolved.
"""
@ -98,9 +98,9 @@ class TestEvolutionCardState:
fully_evolved False (evolution is not complete at creation)
last_evaluated_at None (never evaluated yet)
"""
state = EvolutionCardState.create(player=player, team=team, track=track)
state = RefractorCardState.create(player=player, team=team, track=track)
fetched = EvolutionCardState.get_by_id(state.id)
fetched = RefractorCardState.get_by_id(state.id)
assert fetched.player_id == player.player_id
assert fetched.team_id == team.id
assert fetched.track_id == track.id
@ -113,34 +113,34 @@ class TestEvolutionCardState:
"""A second card state for the same (player, team) pair raises IntegrityError.
The unique index on (player, team) enforces that each player card
has at most one evolution state per team roster slot, preventing
duplicate evolution progress rows for the same physical card.
has at most one refractor state per team roster slot, preventing
duplicate refractor progress rows for the same physical card.
"""
EvolutionCardState.create(player=player, team=team, track=track)
RefractorCardState.create(player=player, team=team, track=track)
with pytest.raises(IntegrityError):
EvolutionCardState.create(player=player, team=team, track=track)
RefractorCardState.create(player=player, team=team, track=track)
def test_card_state_fk_track(self, player, team, track):
"""Accessing card_state.track returns the original EvolutionTrack instance.
"""Accessing card_state.track returns the original RefractorTrack instance.
This confirms the FK is correctly wired and that Peewee resolves
the relationship, returning an object with the same primary key and
name as the track used during creation.
"""
state = EvolutionCardState.create(player=player, team=team, track=track)
fetched = EvolutionCardState.get_by_id(state.id)
state = RefractorCardState.create(player=player, team=team, track=track)
fetched = RefractorCardState.get_by_id(state.id)
resolved_track = fetched.track
assert resolved_track.id == track.id
assert resolved_track.name == "Batter Track"
# ---------------------------------------------------------------------------
# EvolutionTierBoost
# RefractorTierBoost
# ---------------------------------------------------------------------------
class TestEvolutionTierBoost:
"""Tests for EvolutionTierBoost, the per-tier stat/rating bonus table.
class TestRefractorTierBoost:
"""Tests for RefractorTierBoost, the per-tier stat/rating bonus table.
Each row maps a (track, tier) combination to a single boost the
specific stat or rating column to buff and by how much. The four-
@ -153,14 +153,14 @@ class TestEvolutionTierBoost:
Verifies boost_type, boost_target, and boost_value are stored
and retrieved without modification.
"""
boost = EvolutionTierBoost.create(
boost = RefractorTierBoost.create(
track=track,
tier=1,
boost_type="rating",
boost_target="contact_vl",
boost_value=1.5,
)
fetched = EvolutionTierBoost.get_by_id(boost.id)
fetched = RefractorTierBoost.get_by_id(boost.id)
assert fetched.track_id == track.id
assert fetched.tier == 1
assert fetched.boost_type == "rating"
@ -174,7 +174,7 @@ class TestEvolutionTierBoost:
(e.g. Tier-1 contact_vl rating) cannot be defined twice for the
same track, which would create ambiguity during evolution evaluation.
"""
EvolutionTierBoost.create(
RefractorTierBoost.create(
track=track,
tier=2,
boost_type="rating",
@ -182,7 +182,7 @@ class TestEvolutionTierBoost:
boost_value=2.0,
)
with pytest.raises(IntegrityError):
EvolutionTierBoost.create(
RefractorTierBoost.create(
track=track,
tier=2,
boost_type="rating",
@ -192,12 +192,12 @@ class TestEvolutionTierBoost:
# ---------------------------------------------------------------------------
# EvolutionCosmetic
# RefractorCosmetic
# ---------------------------------------------------------------------------
class TestEvolutionCosmetic:
"""Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers.
class TestRefractorCosmetic:
"""Tests for RefractorCosmetic, decorative unlocks tied to evolution tiers.
Cosmetics are purely visual rewards (frames, badges, themes) that a
card unlocks when it reaches a required tier. The name column is
@ -210,14 +210,14 @@ class TestEvolutionCosmetic:
Verifies all columns including optional ones (css_class, asset_url)
are stored and retrieved.
"""
cosmetic = EvolutionCosmetic.create(
cosmetic = RefractorCosmetic.create(
name="Gold Frame",
tier_required=2,
cosmetic_type="frame",
css_class="evo-frame-gold",
asset_url="https://cdn.example.com/frames/gold.png",
)
fetched = EvolutionCosmetic.get_by_id(cosmetic.id)
fetched = RefractorCosmetic.get_by_id(cosmetic.id)
assert fetched.name == "Gold Frame"
assert fetched.tier_required == 2
assert fetched.cosmetic_type == "frame"
@ -227,16 +227,16 @@ class TestEvolutionCosmetic:
def test_cosmetic_unique_name(self):
"""Inserting a second cosmetic with the same name raises IntegrityError.
The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate
The UNIQUE constraint on RefractorCosmetic.name prevents duplicate
cosmetic definitions that could cause ambiguous tier unlock lookups.
"""
EvolutionCosmetic.create(
RefractorCosmetic.create(
name="Silver Badge",
tier_required=1,
cosmetic_type="badge",
)
with pytest.raises(IntegrityError):
EvolutionCosmetic.create(
RefractorCosmetic.create(
name="Silver Badge", # duplicate
tier_required=3,
cosmetic_type="badge",

View File

@ -1,16 +1,16 @@
"""
Tests for app/seed/evolution_tracks.py seed_evolution_tracks().
Tests for app/seed/refractor_tracks.py seed_refractor_tracks().
What: Verify that the JSON-driven seed function correctly creates, counts,
and idempotently updates EvolutionTrack rows in the database.
and idempotently updates RefractorTrack rows in the database.
Why: The seed is the single source of truth for track configuration. A
regression here (duplicates, wrong thresholds, missing formula) would
silently corrupt evolution scoring for every card in the system.
silently corrupt refractor scoring for every card in the system.
Each test operates on a fresh in-memory SQLite database provided by the
autouse `setup_test_db` fixture in conftest.py. The seed reads its data
from `app/seed/evolution_tracks.json` on disk, so the tests also serve as
from `app/seed/refractor_tracks.json` on disk, so the tests also serve as
a light integration check between the JSON file and the Peewee model.
"""
@ -19,11 +19,11 @@ from pathlib import Path
import pytest
from app.db_engine import EvolutionTrack
from app.seed.evolution_tracks import seed_evolution_tracks
from app.db_engine import RefractorTrack
from app.seed.refractor_tracks import seed_refractor_tracks
# Path to the JSON fixture that the seed reads from at runtime
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json"
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "refractor_tracks.json"
@pytest.fixture
@ -37,48 +37,48 @@ def json_tracks():
def test_seed_creates_three_tracks(json_tracks):
"""After one seed call, exactly 3 EvolutionTrack rows must exist.
"""After one seed call, exactly 3 RefractorTrack rows must exist.
Why: The JSON currently defines three card-type tracks (batter, sp, rp).
If the count is wrong the system would either be missing tracks
(evolution disabled for a card type) or have phantom extras.
(refractor disabled for a card type) or have phantom extras.
"""
seed_evolution_tracks()
assert EvolutionTrack.select().count() == 3
seed_refractor_tracks()
assert RefractorTrack.select().count() == 3
def test_seed_correct_card_types(json_tracks):
"""The set of card_type values persisted must match the JSON exactly.
Why: card_type is used as a discriminator throughout the evolution engine.
Why: card_type is used as a discriminator throughout the refractor engine.
An unexpected value (e.g. 'pitcher' instead of 'sp') would cause
track-lookup misses and silently skip evolution scoring for that role.
track-lookup misses and silently skip refractor scoring for that role.
"""
seed_evolution_tracks()
seed_refractor_tracks()
expected_types = {d["card_type"] for d in json_tracks}
actual_types = {t.card_type for t in EvolutionTrack.select()}
actual_types = {t.card_type for t in RefractorTrack.select()}
assert actual_types == expected_types
def test_seed_thresholds_ascending():
"""For every track, t1 < t2 < t3 < t4.
Why: The evolution engine uses these thresholds to determine tier
Why: The refractor engine uses these thresholds to determine tier
boundaries. If they are not strictly ascending, tier comparisons
would produce incorrect or undefined results (e.g. a player could
simultaneously satisfy tier 3 and not satisfy tier 2).
"""
seed_evolution_tracks()
for track in EvolutionTrack.select():
assert (
track.t1_threshold < track.t2_threshold
), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})"
assert (
track.t2_threshold < track.t3_threshold
), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})"
assert (
track.t3_threshold < track.t4_threshold
), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})"
seed_refractor_tracks()
for track in RefractorTrack.select():
assert track.t1_threshold < track.t2_threshold, (
f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})"
)
assert track.t2_threshold < track.t3_threshold, (
f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})"
)
assert track.t3_threshold < track.t4_threshold, (
f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})"
)
def test_seed_thresholds_positive():
@ -86,10 +86,10 @@ def test_seed_thresholds_positive():
Why: A zero or negative threshold would mean a card starts the game
already evolved (tier >= 1 at 0 accumulated stat points), which would
bypass the entire progression system.
bypass the entire refractor progression system.
"""
seed_evolution_tracks()
for track in EvolutionTrack.select():
seed_refractor_tracks()
for track in RefractorTrack.select():
assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive"
assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive"
assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive"
@ -99,29 +99,29 @@ def test_seed_thresholds_positive():
def test_seed_formula_present():
"""Every persisted track must have a non-empty formula string.
Why: The formula is evaluated at runtime to compute a player's evolution
Why: The formula is evaluated at runtime to compute a player's refractor
score. An empty formula would cause either a Python eval error or
silently produce 0 for every player, halting all evolution progress.
silently produce 0 for every player, halting all refractor progress.
"""
seed_evolution_tracks()
for track in EvolutionTrack.select():
assert (
track.formula and track.formula.strip()
), f"{track.name}: formula is empty or whitespace-only"
seed_refractor_tracks()
for track in RefractorTrack.select():
assert track.formula and track.formula.strip(), (
f"{track.name}: formula is empty or whitespace-only"
)
def test_seed_idempotent():
"""Calling seed_evolution_tracks() twice must still yield exactly 3 rows.
"""Calling seed_refractor_tracks() twice must still yield exactly 3 rows.
Why: The seed is designed to be safe to re-run (e.g. as part of a
migration or CI bootstrap). If it inserts duplicates on a second call,
the unique constraint on EvolutionTrack.name would raise an IntegrityError
the unique constraint on RefractorTrack.name would raise an IntegrityError
in PostgreSQL, and in SQLite it would silently create phantom rows that
corrupt tier-lookup joins.
"""
seed_evolution_tracks()
seed_evolution_tracks()
assert EvolutionTrack.select().count() == 3
seed_refractor_tracks()
seed_refractor_tracks()
assert RefractorTrack.select().count() == 3
def test_seed_updates_on_rerun(json_tracks):
@ -135,24 +135,24 @@ def test_seed_updates_on_rerun(json_tracks):
build up silently and the production database would diverge from the
checked-in JSON without any visible error.
"""
seed_evolution_tracks()
seed_refractor_tracks()
# Pick the first track and corrupt its t1_threshold
first_def = json_tracks[0]
track = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
track = RefractorTrack.get(RefractorTrack.name == first_def["name"])
original_t1 = track.t1_threshold
corrupted_value = original_t1 + 9999
track.t1_threshold = corrupted_value
track.save()
# Confirm the corruption took effect before re-seeding
track_check = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
track_check = RefractorTrack.get(RefractorTrack.name == first_def["name"])
assert track_check.t1_threshold == corrupted_value
# Re-seed — should restore the JSON value
seed_evolution_tracks()
seed_refractor_tracks()
restored = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
restored = RefractorTrack.get(RefractorTrack.name == first_def["name"])
assert restored.t1_threshold == first_def["t1_threshold"], (
f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, "
f"got {restored.t1_threshold}"

View File

@ -1,11 +1,11 @@
"""Integration tests for the evolution card state API endpoints (WP-07).
"""Integration tests for the refractor card state API endpoints (WP-07).
Tests cover:
GET /api/v2/teams/{team_id}/evolutions
GET /api/v2/evolution/cards/{card_id}
GET /api/v2/teams/{team_id}/refractors
GET /api/v2/refractor/cards/{card_id}
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and
assume the evolution schema migration (WP-04) has already been applied.
assume the refractor schema migration (WP-04) has already been applied.
Tests auto-skip when POSTGRES_HOST is not set.
Test data is inserted via psycopg2 before each module fixture runs and
@ -18,20 +18,20 @@ Object graph built by fixtures
cardset_row -- a seeded cardset row
player_row -- a seeded player row (FK: rarity, cardset)
team_row -- a seeded team row
track_row -- a seeded evolution_track row (batter)
track_row -- a seeded refractor_track row (batter)
card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset)
state_row -- a seeded evolution_card_state row (FK: player, team, track)
state_row -- a seeded refractor_card_state row (FK: player, team, track)
Test matrix
-----------
test_list_team_evolutions -- baseline: returns count + items for a team
test_list_team_refractors -- baseline: returns count + items for a team
test_list_filter_by_card_type -- card_type query param filters by track.card_type
test_list_filter_by_tier -- tier query param filters by current_tier
test_list_pagination -- page/per_page params slice results correctly
test_get_card_state_shape -- single card returns all required response fields
test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current
test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState
test_get_card_404_no_state -- card with no EvolutionCardState returns 404
test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> RefractorCardState
test_get_card_404_no_state -- card with no RefractorCardState returns 404
test_duplicate_cards_share_state -- two cards same player+team return the same state row
test_auth_required -- missing token returns 401 on both endpoints
"""
@ -63,7 +63,7 @@ def seeded_data(pg_conn):
Insertion order respects FK dependencies:
rarity -> cardset -> player
pack_type (needs cardset) -> pack (needs team + pack_type) -> card
evolution_track -> evolution_card_state
refractor_track -> refractor_card_state
"""
cur = pg_conn.cursor()
@ -130,7 +130,7 @@ def seeded_data(pg_conn):
# Evolution tracks
cur.execute(
"""
INSERT INTO evolution_track (name, card_type, formula,
INSERT INTO refractor_track (name, card_type, formula,
t1_threshold, t2_threshold,
t3_threshold, t4_threshold)
VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896)
@ -142,7 +142,7 @@ def seeded_data(pg_conn):
cur.execute(
"""
INSERT INTO evolution_track (name, card_type, formula,
INSERT INTO refractor_track (name, card_type, formula,
t1_threshold, t2_threshold,
t3_threshold, t4_threshold)
VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240)
@ -230,7 +230,7 @@ def seeded_data(pg_conn):
# Batter player at tier 1, value 87.5
cur.execute(
"""
INSERT INTO evolution_card_state
INSERT INTO refractor_card_state
(player_id, team_id, track_id, current_tier, current_value,
fully_evolved, last_evaluated_at)
VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z')
@ -258,7 +258,7 @@ def seeded_data(pg_conn):
}
# Teardown: delete in reverse FK order
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (state_id,))
cur.execute("DELETE FROM refractor_card_state WHERE id = %s", (state_id,))
cur.execute(
"DELETE FROM card WHERE id = ANY(%s)",
([card_id, card2_id, card_no_state_id],),
@ -266,7 +266,7 @@ def seeded_data(pg_conn):
cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],))
cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,))
cur.execute(
"DELETE FROM evolution_track WHERE id = ANY(%s)",
"DELETE FROM refractor_track WHERE id = ANY(%s)",
([batter_track_id, sp_track_id],),
)
cur.execute(
@ -288,19 +288,19 @@ def client():
# ---------------------------------------------------------------------------
# Tests: GET /api/v2/teams/{team_id}/evolutions
# Tests: GET /api/v2/teams/{team_id}/refractors
# ---------------------------------------------------------------------------
@_skip_no_pg
def test_list_team_evolutions(client, seeded_data):
"""GET /teams/{id}/evolutions returns count=1 and one item for the seeded state.
def test_list_team_refractors(client, seeded_data):
"""GET /teams/{id}/refractors returns count=1 and one item for the seeded state.
Verifies the basic list response shape: a dict with 'count' and 'items',
and that the single item contains player_id, team_id, and current_tier.
"""
team_id = seeded_data["team_id"]
resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER)
resp = client.get(f"/api/v2/teams/{team_id}/refractors", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 1
@ -315,15 +315,15 @@ def test_list_team_evolutions(client, seeded_data):
def test_list_filter_by_card_type(client, seeded_data, pg_conn):
"""card_type filter includes states whose track.card_type matches and excludes others.
Seeds a second evolution_card_state for player2 (sp track) then queries
Seeds a second refractor_card_state for player2 (sp track) then queries
card_type=batter (returns 1) and card_type=sp (returns 1).
Verifies the JOIN to evolution_track and the WHERE predicate on card_type.
Verifies the JOIN to refractor_track and the WHERE predicate on card_type.
"""
cur = pg_conn.cursor()
# Add a state for the sp player so we have two types in this team
cur.execute(
"""
INSERT INTO evolution_card_state
INSERT INTO refractor_card_state
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
VALUES (%s, %s, %s, 0, 0.0, false)
RETURNING id
@ -337,7 +337,7 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn):
team_id = seeded_data["team_id"]
resp_batter = client.get(
f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER
f"/api/v2/teams/{team_id}/refractors?card_type=batter", headers=AUTH_HEADER
)
assert resp_batter.status_code == 200
batter_data = resp_batter.json()
@ -345,14 +345,14 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn):
assert batter_data["items"][0]["player_id"] == seeded_data["player_id"]
resp_sp = client.get(
f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER
f"/api/v2/teams/{team_id}/refractors?card_type=sp", headers=AUTH_HEADER
)
assert resp_sp.status_code == 200
sp_data = resp_sp.json()
assert sp_data["count"] == 1
assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"]
finally:
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,))
cur.execute("DELETE FROM refractor_card_state WHERE id = %s", (sp_state_id,))
pg_conn.commit()
@ -368,7 +368,7 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn):
# Advance to tier 2
cur.execute(
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s",
"UPDATE refractor_card_state SET current_tier = 2 WHERE id = %s",
(seeded_data["state_id"],),
)
pg_conn.commit()
@ -377,13 +377,13 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn):
team_id = seeded_data["team_id"]
resp_t1 = client.get(
f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER
f"/api/v2/teams/{team_id}/refractors?tier=1", headers=AUTH_HEADER
)
assert resp_t1.status_code == 200
assert resp_t1.json()["count"] == 0
resp_t2 = client.get(
f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER
f"/api/v2/teams/{team_id}/refractors?tier=2", headers=AUTH_HEADER
)
assert resp_t2.status_code == 200
t2_data = resp_t2.json()
@ -391,7 +391,7 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn):
assert t2_data["items"][0]["current_tier"] == 2
finally:
cur.execute(
"UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s",
"UPDATE refractor_card_state SET current_tier = 1 WHERE id = %s",
(seeded_data["state_id"],),
)
pg_conn.commit()
@ -408,7 +408,7 @@ def test_list_pagination(client, seeded_data, pg_conn):
cur = pg_conn.cursor()
cur.execute(
"""
INSERT INTO evolution_card_state
INSERT INTO refractor_card_state
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
VALUES (%s, %s, %s, 0, 0.0, false)
RETURNING id
@ -426,14 +426,14 @@ def test_list_pagination(client, seeded_data, pg_conn):
team_id = seeded_data["team_id"]
resp1 = client.get(
f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER
f"/api/v2/teams/{team_id}/refractors?page=1&per_page=1", headers=AUTH_HEADER
)
assert resp1.status_code == 200
data1 = resp1.json()
assert len(data1["items"]) == 1
resp2 = client.get(
f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER
f"/api/v2/teams/{team_id}/refractors?page=2&per_page=1", headers=AUTH_HEADER
)
assert resp2.status_code == 200
data2 = resp2.json()
@ -441,18 +441,18 @@ def test_list_pagination(client, seeded_data, pg_conn):
assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"]
finally:
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,))
cur.execute("DELETE FROM refractor_card_state WHERE id = %s", (extra_state_id,))
pg_conn.commit()
# ---------------------------------------------------------------------------
# Tests: GET /api/v2/evolution/cards/{card_id}
# Tests: GET /api/v2/refractor/cards/{card_id}
# ---------------------------------------------------------------------------
@_skip_no_pg
def test_get_card_state_shape(client, seeded_data):
"""GET /evolution/cards/{card_id} returns all required fields.
"""GET /refractor/cards/{card_id} returns all required fields.
Verifies the full response envelope:
player_id, team_id, current_tier, current_value, fully_evolved,
@ -460,7 +460,7 @@ def test_get_card_state_shape(client, seeded_data):
with id, name, card_type, formula, and t1-t4 thresholds.
"""
card_id = seeded_data["card_id"]
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
@ -505,29 +505,29 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
# Advance to tier 2
cur.execute(
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,)
"UPDATE refractor_card_state SET current_tier = 2 WHERE id = %s", (state_id,)
)
pg_conn.commit()
try:
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
assert resp.json()["next_threshold"] == 448 # t3_threshold
# Advance to tier 4 (fully evolved)
cur.execute(
"UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true "
"UPDATE refractor_card_state SET current_tier = 4, fully_evolved = true "
"WHERE id = %s",
(state_id,),
)
pg_conn.commit()
resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
resp2 = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER)
assert resp2.status_code == 200
assert resp2.json()["next_threshold"] is None
finally:
cur.execute(
"UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false "
"UPDATE refractor_card_state SET current_tier = 1, fully_evolved = false "
"WHERE id = %s",
(state_id,),
)
@ -538,11 +538,11 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
def test_get_card_id_resolves_player(client, seeded_data):
"""card_id is resolved via the Card table to obtain (player_id, team_id).
The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState.
The endpoint must JOIN Card -> Player + Team to find the RefractorCardState.
Verifies that card_id correctly maps to the right player's evolution state.
"""
card_id = seeded_data["card_id"]
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["player_id"] == seeded_data["player_id"]
@ -551,20 +551,20 @@ def test_get_card_id_resolves_player(client, seeded_data):
@_skip_no_pg
def test_get_card_404_no_state(client, seeded_data):
"""GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists.
"""GET /refractor/cards/{card_id} returns 404 when no RefractorCardState exists.
card_no_state_id is a card row for player2 on the team, but no
evolution_card_state row was created for player2. The endpoint must
refractor_card_state row was created for player2. The endpoint must
return 404, not 500 or an empty response.
"""
card_id = seeded_data["card_no_state_id"]
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
resp = client.get(f"/api/v2/refractor/cards/{card_id}", headers=AUTH_HEADER)
assert resp.status_code == 404
@_skip_no_pg
def test_duplicate_cards_share_state(client, seeded_data):
"""Two Card rows for the same player+team share one EvolutionCardState.
"""Two Card rows for the same player+team share one RefractorCardState.
card_id and card2_id both belong to player_id on team_id. Because the
unique-(player,team) constraint means only one state row can exist, both
@ -573,8 +573,8 @@ def test_duplicate_cards_share_state(client, seeded_data):
card1_id = seeded_data["card_id"]
card2_id = seeded_data["card2_id"]
resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER)
resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER)
resp1 = client.get(f"/api/v2/refractor/cards/{card1_id}", headers=AUTH_HEADER)
resp2 = client.get(f"/api/v2/refractor/cards/{card2_id}", headers=AUTH_HEADER)
assert resp1.status_code == 200
assert resp2.status_code == 200
@ -596,14 +596,14 @@ def test_auth_required(client, seeded_data):
"""Both endpoints return 401 when no Bearer token is provided.
Verifies that the valid_token dependency is enforced on:
GET /api/v2/teams/{id}/evolutions
GET /api/v2/evolution/cards/{id}
GET /api/v2/teams/{id}/refractors
GET /api/v2/refractor/cards/{id}
"""
team_id = seeded_data["team_id"]
card_id = seeded_data["card_id"]
resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions")
resp_list = client.get(f"/api/v2/teams/{team_id}/refractors")
assert resp_list.status_code == 401
resp_card = client.get(f"/api/v2/evolution/cards/{card_id}")
resp_card = client.get(f"/api/v2/refractor/cards/{card_id}")
assert resp_card.status_code == 401

View File

@ -1,11 +1,11 @@
"""Integration tests for the evolution track catalog API endpoints (WP-06).
"""Integration tests for the refractor track catalog API endpoints (WP-06).
Tests cover:
GET /api/v2/evolution/tracks
GET /api/v2/evolution/tracks/{track_id}
GET /api/v2/refractor/tracks
GET /api/v2/refractor/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.
assume the refractor 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
@ -45,7 +45,7 @@ def seeded_tracks(pg_conn):
for name, card_type, formula, t1, t2, t3, t4 in _SEED_TRACKS:
cur.execute(
"""
INSERT INTO evolution_track
INSERT INTO refractor_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
@ -62,7 +62,7 @@ def seeded_tracks(pg_conn):
ids.append(cur.fetchone()[0])
pg_conn.commit()
yield ids
cur.execute("DELETE FROM evolution_track WHERE id = ANY(%s)", (ids,))
cur.execute("DELETE FROM refractor_track WHERE id = ANY(%s)", (ids,))
pg_conn.commit()
@ -82,7 +82,7 @@ def test_list_tracks_returns_count_3(client, seeded_tracks):
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)
resp = client.get("/api/v2/refractor/tracks", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 3
@ -92,7 +92,7 @@ def test_list_tracks_returns_count_3(client, seeded_tracks):
@_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)
resp = client.get("/api/v2/refractor/tracks?card_type=sp", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 1
@ -103,7 +103,7 @@ def test_filter_by_card_type(client, seeded_tracks):
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)
resp = client.get(f"/api/v2/refractor/tracks/{track_id}", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["card_type"] == "batter"
@ -117,16 +117,16 @@ def test_get_single_track_with_thresholds(client, seeded_tracks):
@_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)
resp = client.get("/api/v2/refractor/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")
resp_list = client.get("/api/v2/refractor/tracks")
assert resp_list.status_code == 401
track_id = seeded_tracks[0]
resp_single = client.get(f"/api/v2/evolution/tracks/{track_id}")
resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}")
assert resp_single.status_code == 401