refactor: rename evolution system to refractor

Complete rename of the card progression system from "Evolution" to
"Refractor" across all code, routes, models, services, seeds, and tests.

- Route prefix: /api/v2/evolution → /api/v2/refractor
- Model classes: EvolutionTrack → RefractorTrack, etc.
- 12 files renamed, 8 files content-edited
- New migration to rename DB tables
- 117 tests pass, no logic changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-23 13:31:55 -05:00
parent 0b6e85fff9
commit b7dec3f231
20 changed files with 362 additions and 333 deletions

View File

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

View File

@ -51,7 +51,7 @@ from .routers_v2 import ( # noqa: E402
stratplays, stratplays,
scout_opportunities, scout_opportunities,
scout_claims, scout_claims,
evolution, refractor,
season_stats, season_stats,
) )
@ -107,7 +107,7 @@ app.include_router(stratplays.router)
app.include_router(decisions.router) app.include_router(decisions.router)
app.include_router(scout_opportunities.router) app.include_router(scout_opportunities.router)
app.include_router(scout_claims.router) app.include_router(scout_claims.router)
app.include_router(evolution.router) app.include_router(refractor.router)
app.include_router(season_stats.router) app.include_router(season_stats.router)

View File

@ -4,9 +4,19 @@ import logging
import pydantic import pydantic
from pandas import DataFrame 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 ..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_evolution
router = APIRouter(prefix="/api/v2/cards", tags=["cards"]) router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
@ -47,19 +57,25 @@ async def get_cards(
try: try:
this_team = Team.get_by_id(team_id) this_team = Team.get_by_id(team_id)
except DoesNotExist: 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) all_cards = all_cards.where(Card.team == this_team)
if player_id is not None: if player_id is not None:
try: try:
this_player = Player.get_by_id(player_id) this_player = Player.get_by_id(player_id)
except DoesNotExist: 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) all_cards = all_cards.where(Card.player == this_player)
if pack_id is not None: if pack_id is not None:
try: try:
this_pack = Pack.get_by_id(pack_id) this_pack = Pack.get_by_id(pack_id)
except DoesNotExist: 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) all_cards = all_cards.where(Card.pack == this_pack)
if value is not None: if value is not None:
all_cards = all_cards.where(Card.value == value) 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) dex_by_player.setdefault(row.player_id, []).append(row)
return_val = {"count": len(card_list), "cards": []} return_val = {"count": len(card_list), "cards": []}
for x in card_list: for x in card_list:
this_record = model_to_dict(x) this_record = model_to_dict(x)
logging.debug(f"this_record: {this_record}") logging.debug(f"this_record: {this_record}")
@ -147,7 +162,7 @@ async def v1_cards_get_one(card_id, csv: Optional[bool] = False):
try: try:
this_card = Card.get_by_id(card_id) this_card = Card.get_by_id(card_id)
except DoesNotExist: 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: if csv:
data_list = [ data_list = [
@ -215,7 +230,7 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
initialize_card_evolution(x.player_id, x.team_id, card_type) initialize_card_evolution(x.player_id, x.team_id, card_type)
except Exception: except Exception:
logging.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.player_id,
x.team_id, x.team_id,
) )
@ -319,8 +334,8 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
try: try:
this_team = Team.get_by_id(team_id) this_team = Team.get_by_id(team_id)
except DoesNotExist: except DoesNotExist:
logging.error(f'/cards/wipe-team/{team_id} - could not find team') logging.error(f"/cards/wipe-team/{team_id} - could not find team")
raise HTTPException(status_code=404, detail=f'Team {team_id} not found') raise HTTPException(status_code=404, detail=f"Team {team_id} not found")
t_query = Card.update(team=None).where(Card.team == this_team).execute() t_query = Card.update(team=None).where(Card.team == this_team).execute()
return f"Wiped {t_query} cards" return f"Wiped {t_query} cards"
@ -348,7 +363,7 @@ async def v1_cards_patch(
try: try:
this_card = Card.get_by_id(card_id) this_card = Card.get_by_id(card_id)
except DoesNotExist: 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: if player_id is not None:
this_card.player_id = player_id this_card.player_id = player_id
@ -391,7 +406,7 @@ async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)):
try: try:
this_card = Card.get_by_id(card_id) this_card = Card.get_by_id(card_id)
except DoesNotExist: 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() count = this_card.delete_instance()

View File

@ -7,10 +7,10 @@ from ..dependencies import oauth2_scheme, valid_token
logger = logging.getLogger(__name__) 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 # 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). # Tier 4 is fully evolved so there is no next threshold (None sentinel).
_NEXT_THRESHOLD_ATTR = { _NEXT_THRESHOLD_ATTR = {
0: "t1_threshold", 0: "t1_threshold",
@ -22,7 +22,7 @@ _NEXT_THRESHOLD_ATTR = {
def _build_card_state_response(state) -> dict: 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, Produces a flat dict with player_id and team_id as plain integers,
a nested 'track' dict with all threshold fields, and a computed a nested 'track' dict with all threshold fields, and a computed
@ -63,11 +63,11 @@ async def list_tracks(
logging.warning("Bad Token: [REDACTED]") logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized") 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: 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] items = [model_to_dict(t, recurse=False) for t in query]
return {"count": len(items), "items": items} 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]") logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionTrack from ..db_engine import RefractorTrack
try: try:
track = EvolutionTrack.get_by_id(track_id) track = RefractorTrack.get_by_id(track_id)
except Exception: except Exception:
raise HTTPException(status_code=404, detail=f"Track {track_id} not found") 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}") @router.get("/cards/{card_id}")
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): 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 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), 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. any card_id belonging to that player on that team returns the same state.
Returns 404 when: Returns 404 when:
- The card_id does not exist in the Card table. - 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): if not valid_token(token):
logging.warning("Bad Token: [REDACTED]") logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized") 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 # Resolve card_id to player+team
try: try:
@ -114,22 +114,22 @@ async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f"Card {card_id} not found") 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. # track so a single query resolves both rows.
try: try:
state = ( state = (
EvolutionCardState.select(EvolutionCardState, EvolutionTrack) RefractorCardState.select(RefractorCardState, RefractorTrack)
.join(EvolutionTrack) .join(RefractorTrack)
.where( .where(
(EvolutionCardState.player == card.player_id) (RefractorCardState.player == card.player_id)
& (EvolutionCardState.team == card.team_id) & (RefractorCardState.team == card.team_id)
) )
.get() .get()
) )
except DoesNotExist: except DoesNotExist:
raise HTTPException( raise HTTPException(
status_code=404, 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) 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") @router.post("/cards/{card_id}/evaluate")
async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)): 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. tier from all player_season_stats rows for that pair. Idempotent.
""" """
if not valid_token(token): 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") raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import Card from ..db_engine import Card
from ..services.evolution_evaluator import evaluate_card as _evaluate from ..services.refractor_evaluator import evaluate_card as _evaluate
try: try:
card = Card.get_by_id(card_id) 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}") @router.post("/evaluate-game/{game_id}")
async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): 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, 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 tier. Pairs without a state row are silently skipped. Per-player errors are
logged but do not abort the batch. 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]") logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionCardState, EvolutionTrack, Player, StratPlay from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay
from ..services.evolution_evaluator import evaluate_card from ..services.refractor_evaluator import evaluate_card
plays = list(StratPlay.select().where(StratPlay.game == game_id)) 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: for player_id, team_id in pairs:
try: try:
state = EvolutionCardState.get_or_none( state = RefractorCardState.get_or_none(
(EvolutionCardState.player_id == player_id) (RefractorCardState.player_id == player_id)
& (EvolutionCardState.team_id == team_id) & (RefractorCardState.team_id == team_id)
) )
if state is None: if state is None:
continue continue
@ -225,7 +225,7 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
) )
except Exception as exc: except Exception as exc:
logger.warning( 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} 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.", detail=f"You are not authorized to buy {this_team.abbrev} cards. This event has been logged.",
) )
all_ids = ids.split(",") all_ids = ids.split(",")
conf_message = "" conf_message = ""
total_cost = 0 total_cost = 0
@ -1540,9 +1539,9 @@ async def list_team_evolutions(
per_page: int = Query(default=10, ge=1, le=100), per_page: int = Query(default=10, ge=1, le=100),
token: str = Depends(oauth2_scheme), 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 works without a second query. Results are paginated via page/per_page
(1-indexed pages); items are ordered by player_id for stable ordering. (1-indexed pages); items are ordered by player_id for stable ordering.
@ -1555,27 +1554,27 @@ async def list_team_evolutions(
Response shape: Response shape:
{"count": N, "items": [card_state_with_threshold_context, ...]} {"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): if not valid_token(token):
logging.warning("Bad Token: [REDACTED]") logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionCardState, EvolutionTrack from ..db_engine import RefractorCardState, RefractorTrack
from ..routers_v2.evolution import _build_card_state_response from ..routers_v2.refractor import _build_card_state_response
query = ( query = (
EvolutionCardState.select(EvolutionCardState, EvolutionTrack) RefractorCardState.select(RefractorCardState, RefractorTrack)
.join(EvolutionTrack) .join(RefractorTrack)
.where(EvolutionCardState.team == team_id) .where(RefractorCardState.team == team_id)
.order_by(EvolutionCardState.player_id) .order_by(RefractorCardState.player_id)
) )
if card_type is not None: 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: if tier is not None:
query = query.where(EvolutionCardState.current_tier == tier) query = query.where(RefractorCardState.current_tier == tier)
total = query.count() total = query.count()
offset = (page - 1) * per_page 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 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. thresholds and formula updated to match the JSON in case values have changed.
Can be run standalone: Can be run standalone:
python -m app.seed.evolution_tracks python -m app.seed.refractor_tracks
""" """
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from app.db_engine import EvolutionTrack from app.db_engine import RefractorTrack
logger = logging.getLogger(__name__) 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]: def seed_refractor_tracks() -> list[RefractorTrack]:
"""Upsert evolution tracks from JSON seed data. """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") raw = _JSON_PATH.read_text(encoding="utf-8")
track_defs = json.loads(raw) track_defs = json.loads(raw)
results: list[EvolutionTrack] = [] results: list[RefractorTrack] = []
for defn in track_defs: for defn in track_defs:
track, created = EvolutionTrack.get_or_create( track, created = RefractorTrack.get_or_create(
name=defn["name"], name=defn["name"],
defaults={ defaults={
"card_type": defn["card_type"], "card_type": defn["card_type"],
@ -61,6 +61,6 @@ def seed_evolution_tracks() -> list[EvolutionTrack]:
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger.info("Seeding evolution tracks...") logger.info("Seeding refractor tracks...")
tracks = seed_evolution_tracks() tracks = seed_refractor_tracks()
logger.info("Done. %d track(s) processed.", len(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. plus helpers for formula dispatch and tier classification.
Stats attributes expected by each formula: 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: 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: Tier boundaries are inclusive on the lower end:
T0: value < t1 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: evaluate_card() is the main entry point:
1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id) 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. 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 and WP-09 (formula engine). Models and formula functions are imported lazily so
this module can be imported before those PRs merge. this module can be imported before those PRs merge.
""" """
@ -52,11 +52,11 @@ def evaluate_card(
_compute_value_fn=None, _compute_value_fn=None,
_tier_from_value_fn=None, _tier_from_value_fn=None,
) -> dict: ) -> 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 Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
card_type) for (player_id, team_id) across all seasons, then delegates 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. returned as a dict.
current_tier never decreases (no regression): current_tier never decreases (no regression):
@ -67,7 +67,7 @@ def evaluate_card(
team_id: Team primary key. team_id: Team primary key.
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats _stats_model: Override for BattingSeasonStats/PitchingSeasonStats
(used in tests to inject a stub model with all stat fields). (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). importing from db_engine before WP-05 merges).
_compute_value_fn: Override for formula_engine.compute_value_for_track _compute_value_fn: Override for formula_engine.compute_value_for_track
(used in tests to avoid importing formula_engine before WP-09 merges). (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). last_evaluated_at (ISO-8601 string).
Raises: 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: 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: if _compute_value_fn is None or _tier_from_value_fn is None:
from app.services.formula_engine import ( # noqa: PLC0415 from app.services.formula_engine import ( # noqa: PLC0415
@ -101,7 +101,7 @@ def evaluate_card(
) )
if card_state is None: if card_state is None:
raise ValueError( 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 # 2. Load career totals from the appropriate season stats table
@ -178,7 +178,7 @@ def evaluate_card(
card_state.save() card_state.save()
logging.debug( 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, player_id,
team_id, team_id,
value, 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 Public API
---------- ----------
initialize_card_evolution(player_id, team_id, card_type) initialize_card_evolution(player_id, team_id, card_type)
Get-or-create an EvolutionCardState for the (player_id, team_id) pair. Get-or-create a RefractorCardState for the (player_id, team_id) pair.
Returns the state instance on success, or None if initialization fails Returns the state instance on success, or None if initialization fails
(missing track, integrity error, etc.). Never raises. (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. - The function is intentionally fire-and-forget from the caller's perspective.
All exceptions are caught and logged; pack opening is never blocked. 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). separate concern handled by the stats-update pipeline (WP-07/WP-08).
- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern); - 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. so that future rule changes don't require back-filling.
""" """
import logging import logging
from typing import Optional from typing import Optional
from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack from app.db_engine import DoesNotExist, RefractorCardState, RefractorTrack
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _determine_card_type(player) -> str: 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): Rules (from WP-10 spec):
- pos_1 contains 'SP' -> 'sp' - pos_1 contains 'SP' -> 'sp'
@ -57,30 +57,30 @@ def initialize_card_evolution(
player_id: int, player_id: int,
team_id: int, team_id: int,
card_type: str, card_type: str,
) -> Optional[EvolutionCardState]: ) -> Optional[RefractorCardState]:
"""Get-or-create an EvolutionCardState for a newly acquired card. """Get-or-create a RefractorCardState for a newly acquired card.
Called by the cards POST endpoint after each card is inserted. The Called by the cards POST endpoint after each card is inserted. The
function is idempotent: if a state row already exists for the function is idempotent: if a state row already exists for the
(player_id, team_id) pair it is returned unchanged existing (player_id, team_id) pair it is returned unchanged existing
evolution progress is never reset. refractor progress is never reset.
Args: Args:
player_id: Primary key of the Player row (Player.player_id). player_id: Primary key of the Player row (Player.player_id).
team_id: Primary key of the Team row (Team.id). team_id: Primary key of the Team row (Team.id).
card_type: One of 'batter', 'sp', 'rp'. Determines which card_type: One of 'batter', 'sp', 'rp'. Determines which
EvolutionTrack is assigned to the new state. RefractorTrack is assigned to the new state.
Returns: 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 None if initialization could not complete (missing track seed
data, unexpected DB error, etc.). data, unexpected DB error, etc.).
""" """
try: try:
track = EvolutionTrack.get(EvolutionTrack.card_type == card_type) track = RefractorTrack.get(RefractorTrack.card_type == card_type)
except DoesNotExist: except DoesNotExist:
logger.warning( 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", "(player_id=%s, team_id=%s) — skipping state creation",
card_type, card_type,
player_id, player_id,
@ -89,7 +89,7 @@ def initialize_card_evolution(
return None return None
except Exception: except Exception:
logger.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=%r, player_id=%s, team_id=%s)",
card_type, card_type,
player_id, player_id,
@ -98,7 +98,7 @@ def initialize_card_evolution(
return None return None
try: try:
state, created = EvolutionCardState.get_or_create( state, created = RefractorCardState.get_or_create(
player_id=player_id, player_id=player_id,
team_id=team_id, team_id=team_id,
defaults={ defaults={
@ -110,7 +110,7 @@ def initialize_card_evolution(
) )
if created: if created:
logger.debug( logger.debug(
"evolution_init: created EvolutionCardState id=%s " "refractor_init: created RefractorCardState id=%s "
"(player_id=%s, team_id=%s, card_type=%r)", "(player_id=%s, team_id=%s, card_type=%r)",
state.id, state.id,
player_id, player_id,
@ -119,7 +119,7 @@ def initialize_card_evolution(
) )
else: else:
logger.debug( logger.debug(
"evolution_init: state already exists id=%s " "refractor_init: state already exists id=%s "
"(player_id=%s, team_id=%s) — no-op", "(player_id=%s, team_id=%s) — no-op",
state.id, state.id,
player_id, player_id,
@ -129,7 +129,7 @@ def initialize_card_evolution(
except Exception: except Exception:
logger.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=%s, team_id=%s, card_type=%r)",
player_id, player_id,
team_id, team_id,

View File

@ -0,0 +1,15 @@
-- 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;

View File

@ -44,10 +44,10 @@ from app.db_engine import (
BattingSeasonStats, BattingSeasonStats,
PitchingSeasonStats, PitchingSeasonStats,
ProcessedGame, ProcessedGame,
EvolutionTrack, RefractorTrack,
EvolutionCardState, RefractorCardState,
EvolutionTierBoost, RefractorTierBoost,
EvolutionCosmetic, RefractorCosmetic,
ScoutOpportunity, ScoutOpportunity,
ScoutClaim, ScoutClaim,
) )
@ -76,10 +76,10 @@ _TEST_MODELS = [
ProcessedGame, ProcessedGame,
ScoutOpportunity, ScoutOpportunity,
ScoutClaim, ScoutClaim,
EvolutionTrack, RefractorTrack,
EvolutionCardState, RefractorCardState,
EvolutionTierBoost, RefractorTierBoost,
EvolutionCosmetic, RefractorCosmetic,
] ]
@ -164,8 +164,8 @@ def team():
@pytest.fixture @pytest.fixture
def track(): def track():
"""A minimal EvolutionTrack for batter cards.""" """A minimal RefractorTrack for batter cards."""
return EvolutionTrack.create( return RefractorTrack.create(
name="Batter Track", name="Batter Track",
card_type="batter", card_type="batter",
formula="pa + tb * 2", 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 Unit tests only no database required. Stats inputs are simple namespace
objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose. 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 Batter: t1=37, t2=149, t3=448, t4=896
SP: t1=10, t2=40, t3=120, t4=240 SP: t1=10, t2=40, t3=120, t4=240
RP: t1=3, t2=12, t3=35, t4=70 RP: t1=3, t2=12, t3=35, t4=70

View File

@ -2,7 +2,7 @@
Tests cover both post-game callback endpoints: Tests cover both post-game callback endpoints:
POST /api/v2/season-stats/update-game/{game_id} 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 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 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 test_evaluate_game_tier_ups_in_response
Tier-up appears in tier_ups list with correct fields. Tier-up appears in tier_ups list with correct fields.
test_evaluate_game_skips_players_without_state 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 test_auth_required_update_game
Missing bearer token returns 401 on update-game. Missing bearer token returns 401 on update-game.
test_auth_required_evaluate_game test_auth_required_evaluate_game
@ -55,10 +55,10 @@ from peewee import SqliteDatabase
from app.db_engine import ( from app.db_engine import (
Cardset, Cardset,
EvolutionCardState, RefractorCardState,
EvolutionCosmetic, RefractorCosmetic,
EvolutionTierBoost, RefractorTierBoost,
EvolutionTrack, RefractorTrack,
MlbPlayer, MlbPlayer,
Pack, Pack,
PackType, PackType,
@ -111,10 +111,10 @@ _WP13_MODELS = [
BattingSeasonStats, BattingSeasonStats,
PitchingSeasonStats, PitchingSeasonStats,
ProcessedGame, ProcessedGame,
EvolutionTrack, RefractorTrack,
EvolutionCardState, RefractorCardState,
EvolutionTierBoost, RefractorTierBoost,
EvolutionCosmetic, RefractorCosmetic,
] ]
# Patch the service-layer 'db' reference to use our shared test database so # 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. 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.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() test_app = FastAPI()
@ -294,8 +294,8 @@ def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **sta
def _make_track( def _make_track(
name: str = "WP13 Batter Track", card_type: str = "batter" name: str = "WP13 Batter Track", card_type: str = "batter"
) -> EvolutionTrack: ) -> RefractorTrack:
track, _ = EvolutionTrack.get_or_create( track, _ = RefractorTrack.get_or_create(
name=name, name=name,
defaults=dict( defaults=dict(
card_type=card_type, card_type=card_type,
@ -311,8 +311,8 @@ def _make_track(
def _make_state( def _make_state(
player, team, track, current_tier=0, current_value=0.0 player, team, track, current_tier=0, current_value=0.0
) -> EvolutionCardState: ) -> RefractorCardState:
return EvolutionCardState.create( return RefractorCardState.create(
player=player, player=player,
team=team, team=team,
track=track, 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): def test_evaluate_game_increases_current_value(client):
"""After update-game, evaluate-game raises the card's current_value above 0. """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 game. update-game writes those stats; evaluate-game then recomputes the
value. current_value in the DB must be > 0 after the evaluate call. 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) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post( 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 assert resp.status_code == 200
state = EvolutionCardState.get( state = RefractorCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) (RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
) )
assert state.current_value > 0 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) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post( 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 assert resp.status_code == 200
updated_state = EvolutionCardState.get( updated_state = RefractorCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) (RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
) )
assert updated_state.current_tier >= 1 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) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post( 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["tier_ups"] == [] assert data["tier_ups"] == []
state = EvolutionCardState.get( state = RefractorCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) (RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
) )
assert state.current_tier == 0 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) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post( 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 assert resp.status_code == 200
data = resp.json() 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): 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. 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) 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) game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Skip Track") 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_state(batter_with_state, team_a, track)
_make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1) _make_play(game, 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) client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post( 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
@ -663,5 +663,5 @@ def test_auth_required_evaluate_game(client):
team_b = _make_team("WB2", gmid=20092) team_b = _make_team("WB2", gmid=20092)
game = _make_game(team_a, team_b) 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 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, 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. 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 The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported
@ -33,7 +33,7 @@ from peewee import (
SqliteDatabase, 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 # 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): class TrackStub(Model):
"""Minimal EvolutionTrack stub for evaluator tests.""" """Minimal RefractorTrack stub for evaluator tests."""
card_type = CharField(unique=True) card_type = CharField(unique=True)
t1_threshold = IntegerField() t1_threshold = IntegerField()
@ -53,11 +53,11 @@ class TrackStub(Model):
class Meta: class Meta:
database = _test_db database = _test_db
table_name = "evolution_track" table_name = "refractor_track"
class CardStateStub(Model): class CardStateStub(Model):
"""Minimal EvolutionCardState stub for evaluator tests.""" """Minimal RefractorCardState stub for evaluator tests."""
player_id = IntegerField() player_id = IntegerField()
team_id = IntegerField() team_id = IntegerField()
@ -69,7 +69,7 @@ class CardStateStub(Model):
class Meta: class Meta:
database = _test_db database = _test_db
table_name = "evolution_card_state" table_name = "refractor_card_state"
indexes = ((("player_id", "team_id"), True),) indexes = ((("player_id", "team_id"), True),)
@ -331,7 +331,7 @@ class TestMissingState:
def test_missing_state_raises(self, batter_track): def test_missing_state_raises(self, batter_track):
"""evaluate_card raises ValueError when no state row exists.""" """evaluate_card raises ValueError when no state row exists."""
# No card state created # 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) _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` Covers `app/services/refractor_init.py` the `initialize_card_evolution`
function that creates an EvolutionCardState row when a card is first acquired. function that creates an RefractorCardState row when a card is first acquired.
Test strategy: Test strategy:
- Unit tests for `_determine_card_type` cover all three branches (batter, - 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: Why we test cross-player isolation:
Two different players with the same team must each get their own 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. across players, so we assert that state.player_id matches.
""" """
@ -26,11 +26,11 @@ import pytest
from app.db_engine import ( from app.db_engine import (
Cardset, Cardset,
EvolutionCardState, RefractorCardState,
EvolutionTrack, RefractorTrack,
Player, Player,
) )
from app.services.evolution_init import _determine_card_type, initialize_card_evolution from app.services.refractor_init import _determine_card_type, initialize_card_evolution
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -74,13 +74,13 @@ def _make_player(rarity, pos_1: str) -> Player:
) )
def _make_track(card_type: str) -> EvolutionTrack: def _make_track(card_type: str) -> RefractorTrack:
"""Create an EvolutionTrack for the given card_type. """Create an RefractorTrack for the given card_type.
Thresholds are kept small and arbitrary; the unit under test only Thresholds are kept small and arbitrary; the unit under test only
cares about card_type when selecting the track. cares about card_type when selecting the track.
""" """
return EvolutionTrack.create( return RefractorTrack.create(
name=f"Track-{card_type}", name=f"Track-{card_type}",
card_type=card_type, card_type=card_type,
formula="pa", formula="pa",
@ -116,14 +116,14 @@ class TestDetermineCardType:
"""pos_1 == 'RP' maps to card_type 'rp'. """pos_1 == 'RP' maps to card_type 'rp'.
Relief pitchers carry the 'RP' position flag and must follow a 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" assert _determine_card_type(_FakePlayer("RP")) == "rp"
def test_closer_pitcher(self): def test_closer_pitcher(self):
"""pos_1 == 'CP' maps to card_type 'rp'. """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. as an rp-track position.
""" """
assert _determine_card_type(_FakePlayer("CP")) == "rp" assert _determine_card_type(_FakePlayer("CP")) == "rp"
@ -168,7 +168,7 @@ class TestInitializeCardEvolution:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def seed_tracks(self): 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_evolution does a DB lookup for a track matching the
card_type. If no track exists the function must not crash (it should card_type. If no track exists the function must not crash (it should
@ -180,7 +180,7 @@ class TestInitializeCardEvolution:
self.rp_track = _make_track("rp") self.rp_track = _make_track("rp")
def test_first_card_creates_state(self, rarity, team): 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: Acceptance criteria from WP-10:
- current_tier == 0 - current_tier == 0
@ -222,17 +222,17 @@ class TestInitializeCardEvolution:
# Exactly one row in the database # Exactly one row in the database
count = ( count = (
EvolutionCardState.select() RefractorCardState.select()
.where( .where(
EvolutionCardState.player == player, RefractorCardState.player == player,
EvolutionCardState.team == team, RefractorCardState.team == team,
) )
.count() .count()
) )
assert count == 1 assert count == 1
# Progress was NOT reset # 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_tier == 2
assert refreshed.current_value == 250.0 assert refreshed.current_value == 250.0
@ -256,7 +256,7 @@ class TestInitializeCardEvolution:
assert state_b.player_id == player_b.player_id assert state_b.player_id == player_b.player_id
def test_sp_card_gets_sp_track(self, rarity, team): 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 Track selection is driven by card_type, which in turn comes from
pos_1. This test passes card_type='sp' explicitly (mirroring the pos_1. This test passes card_type='sp' explicitly (mirroring the
@ -270,7 +270,7 @@ class TestInitializeCardEvolution:
assert state.track_id == self.sp_track.id assert state.track_id == self.sp_track.id
def test_rp_card_gets_rp_track(self, rarity, team): 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") player = _make_player(rarity, "RP")
state = initialize_card_evolution(player.player_id, team.id, "rp") state = initialize_card_evolution(player.player_id, team.id, "rp")

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: Covers WP-01 acceptance criteria:
- EvolutionTrack: CRUD and unique-name constraint - RefractorTrack: CRUD and unique-name constraint
- EvolutionCardState: CRUD, defaults, unique-(player,team) constraint, - RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
and FK resolution back to EvolutionTrack and FK resolution back to RefractorTrack
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target) - RefractorTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
- EvolutionCosmetic: CRUD and unique-name constraint - RefractorCosmetic: CRUD and unique-name constraint
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season), - BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
and in-place stat accumulation and in-place stat accumulation
@ -21,21 +21,21 @@ from playhouse.shortcuts import model_to_dict
from app.db_engine import ( from app.db_engine import (
BattingSeasonStats, BattingSeasonStats,
EvolutionCardState, RefractorCardState,
EvolutionCosmetic, RefractorCosmetic,
EvolutionTierBoost, RefractorTierBoost,
EvolutionTrack, RefractorTrack,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# EvolutionTrack # RefractorTrack
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEvolutionTrack: class TestRefractorTrack:
"""Tests for the EvolutionTrack model. """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 tier thresholds) for a card type. The name column carries a
UNIQUE constraint so that accidental duplicates are caught at UNIQUE constraint so that accidental duplicates are caught at
the database level. the database level.
@ -60,12 +60,12 @@ class TestEvolutionTrack:
def test_track_unique_name(self, track): def test_track_unique_name(self, track):
"""Inserting a second track with the same name raises IntegrityError. """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 tracks from sharing the same identifier, as the name is used as
a human-readable key throughout the evolution system. a human-readable key throughout the evolution system.
""" """
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
EvolutionTrack.create( RefractorTrack.create(
name="Batter Track", # duplicate name="Batter Track", # duplicate
card_type="sp", card_type="sp",
formula="outs * 3", formula="outs * 3",
@ -77,15 +77,15 @@ class TestEvolutionTrack:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# EvolutionCardState # RefractorCardState
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEvolutionCardState: class TestRefractorCardState:
"""Tests for EvolutionCardState, which tracks per-player evolution progress. """Tests for RefractorCardState, which tracks per-player refractor progress.
Each row represents one card (player) owned by one team, linked to a 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. 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) fully_evolved False (evolution is not complete at creation)
last_evaluated_at None (never evaluated yet) 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.player_id == player.player_id
assert fetched.team_id == team.id assert fetched.team_id == team.id
assert fetched.track_id == track.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. """A second card state for the same (player, team) pair raises IntegrityError.
The unique index on (player, team) enforces that each player card The unique index on (player, team) enforces that each player card
has at most one evolution state per team roster slot, preventing has at most one refractor state per team roster slot, preventing
duplicate evolution progress rows for the same physical card. 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): 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): 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 This confirms the FK is correctly wired and that Peewee resolves
the relationship, returning an object with the same primary key and the relationship, returning an object with the same primary key and
name as the track used during creation. name as the track used during creation.
""" """
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)
resolved_track = fetched.track resolved_track = fetched.track
assert resolved_track.id == track.id assert resolved_track.id == track.id
assert resolved_track.name == "Batter Track" assert resolved_track.name == "Batter Track"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# EvolutionTierBoost # RefractorTierBoost
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEvolutionTierBoost: class TestRefractorTierBoost:
"""Tests for EvolutionTierBoost, the per-tier stat/rating bonus table. """Tests for RefractorTierBoost, the per-tier stat/rating bonus table.
Each row maps a (track, tier) combination to a single boost the 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- 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 Verifies boost_type, boost_target, and boost_value are stored
and retrieved without modification. and retrieved without modification.
""" """
boost = EvolutionTierBoost.create( boost = RefractorTierBoost.create(
track=track, track=track,
tier=1, tier=1,
boost_type="rating", boost_type="rating",
boost_target="contact_vl", boost_target="contact_vl",
boost_value=1.5, 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.track_id == track.id
assert fetched.tier == 1 assert fetched.tier == 1
assert fetched.boost_type == "rating" assert fetched.boost_type == "rating"
@ -174,7 +174,7 @@ class TestEvolutionTierBoost:
(e.g. Tier-1 contact_vl rating) cannot be defined twice for the (e.g. Tier-1 contact_vl rating) cannot be defined twice for the
same track, which would create ambiguity during evolution evaluation. same track, which would create ambiguity during evolution evaluation.
""" """
EvolutionTierBoost.create( RefractorTierBoost.create(
track=track, track=track,
tier=2, tier=2,
boost_type="rating", boost_type="rating",
@ -182,7 +182,7 @@ class TestEvolutionTierBoost:
boost_value=2.0, boost_value=2.0,
) )
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
EvolutionTierBoost.create( RefractorTierBoost.create(
track=track, track=track,
tier=2, tier=2,
boost_type="rating", boost_type="rating",
@ -192,12 +192,12 @@ class TestEvolutionTierBoost:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# EvolutionCosmetic # RefractorCosmetic
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestEvolutionCosmetic: class TestRefractorCosmetic:
"""Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers. """Tests for RefractorCosmetic, decorative unlocks tied to evolution tiers.
Cosmetics are purely visual rewards (frames, badges, themes) that a Cosmetics are purely visual rewards (frames, badges, themes) that a
card unlocks when it reaches a required tier. The name column is 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) Verifies all columns including optional ones (css_class, asset_url)
are stored and retrieved. are stored and retrieved.
""" """
cosmetic = EvolutionCosmetic.create( cosmetic = RefractorCosmetic.create(
name="Gold Frame", name="Gold Frame",
tier_required=2, tier_required=2,
cosmetic_type="frame", cosmetic_type="frame",
css_class="evo-frame-gold", css_class="evo-frame-gold",
asset_url="https://cdn.example.com/frames/gold.png", 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.name == "Gold Frame"
assert fetched.tier_required == 2 assert fetched.tier_required == 2
assert fetched.cosmetic_type == "frame" assert fetched.cosmetic_type == "frame"
@ -227,16 +227,16 @@ class TestEvolutionCosmetic:
def test_cosmetic_unique_name(self): def test_cosmetic_unique_name(self):
"""Inserting a second cosmetic with the same name raises IntegrityError. """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. cosmetic definitions that could cause ambiguous tier unlock lookups.
""" """
EvolutionCosmetic.create( RefractorCosmetic.create(
name="Silver Badge", name="Silver Badge",
tier_required=1, tier_required=1,
cosmetic_type="badge", cosmetic_type="badge",
) )
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
EvolutionCosmetic.create( RefractorCosmetic.create(
name="Silver Badge", # duplicate name="Silver Badge", # duplicate
tier_required=3, tier_required=3,
cosmetic_type="badge", 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, 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 Why: The seed is the single source of truth for track configuration. A
regression here (duplicates, wrong thresholds, missing formula) would 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 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 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. a light integration check between the JSON file and the Peewee model.
""" """
@ -19,11 +19,11 @@ from pathlib import Path
import pytest import pytest
from app.db_engine import EvolutionTrack from app.db_engine import RefractorTrack
from app.seed.evolution_tracks import seed_evolution_tracks from app.seed.refractor_tracks import seed_refractor_tracks
# Path to the JSON fixture that the seed reads from at runtime # 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 @pytest.fixture
@ -37,48 +37,48 @@ def json_tracks():
def test_seed_creates_three_tracks(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). Why: The JSON currently defines three card-type tracks (batter, sp, rp).
If the count is wrong the system would either be missing tracks 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() seed_refractor_tracks()
assert EvolutionTrack.select().count() == 3 assert RefractorTrack.select().count() == 3
def test_seed_correct_card_types(json_tracks): def test_seed_correct_card_types(json_tracks):
"""The set of card_type values persisted must match the JSON exactly. """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 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} 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 assert actual_types == expected_types
def test_seed_thresholds_ascending(): def test_seed_thresholds_ascending():
"""For every track, t1 < t2 < t3 < t4. """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 boundaries. If they are not strictly ascending, tier comparisons
would produce incorrect or undefined results (e.g. a player could would produce incorrect or undefined results (e.g. a player could
simultaneously satisfy tier 3 and not satisfy tier 2). simultaneously satisfy tier 3 and not satisfy tier 2).
""" """
seed_evolution_tracks() seed_refractor_tracks()
for track in EvolutionTrack.select(): for track in RefractorTrack.select():
assert ( assert track.t1_threshold < track.t2_threshold, (
track.t1_threshold < track.t2_threshold f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})"
), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})" )
assert ( assert track.t2_threshold < track.t3_threshold, (
track.t2_threshold < track.t3_threshold f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})"
), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})" )
assert ( assert track.t3_threshold < track.t4_threshold, (
track.t3_threshold < track.t4_threshold f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})"
), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})" )
def test_seed_thresholds_positive(): 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 Why: A zero or negative threshold would mean a card starts the game
already evolved (tier >= 1 at 0 accumulated stat points), which would 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() seed_refractor_tracks()
for track in EvolutionTrack.select(): for track in RefractorTrack.select():
assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive" 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.t2_threshold > 0, f"{track.name}: t2_threshold is not positive"
assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive" assert track.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(): def test_seed_formula_present():
"""Every persisted track must have a non-empty formula string. """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 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() seed_refractor_tracks()
for track in EvolutionTrack.select(): for track in RefractorTrack.select():
assert ( assert track.formula and track.formula.strip(), (
track.formula and track.formula.strip() f"{track.name}: formula is empty or whitespace-only"
), f"{track.name}: formula is empty or whitespace-only" )
def test_seed_idempotent(): 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 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, 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 in PostgreSQL, and in SQLite it would silently create phantom rows that
corrupt tier-lookup joins. corrupt tier-lookup joins.
""" """
seed_evolution_tracks() seed_refractor_tracks()
seed_evolution_tracks() seed_refractor_tracks()
assert EvolutionTrack.select().count() == 3 assert RefractorTrack.select().count() == 3
def test_seed_updates_on_rerun(json_tracks): 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 build up silently and the production database would diverge from the
checked-in JSON without any visible error. checked-in JSON without any visible error.
""" """
seed_evolution_tracks() seed_refractor_tracks()
# Pick the first track and corrupt its t1_threshold # Pick the first track and corrupt its t1_threshold
first_def = json_tracks[0] 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 original_t1 = track.t1_threshold
corrupted_value = original_t1 + 9999 corrupted_value = original_t1 + 9999
track.t1_threshold = corrupted_value track.t1_threshold = corrupted_value
track.save() track.save()
# Confirm the corruption took effect before re-seeding # 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 assert track_check.t1_threshold == corrupted_value
# Re-seed — should restore the JSON 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"], ( assert restored.t1_threshold == first_def["t1_threshold"], (
f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, " f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, "
f"got {restored.t1_threshold}" 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: Tests cover:
GET /api/v2/teams/{team_id}/evolutions GET /api/v2/teams/{team_id}/evolutions
GET /api/v2/evolution/cards/{card_id} GET /api/v2/refractor/cards/{card_id}
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and 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. Tests auto-skip when POSTGRES_HOST is not set.
Test data is inserted via psycopg2 before each module fixture runs and Test data is inserted via psycopg2 before each module fixture runs and
@ -18,9 +18,9 @@ Object graph built by fixtures
cardset_row -- a seeded cardset row cardset_row -- a seeded cardset row
player_row -- a seeded player row (FK: rarity, cardset) player_row -- a seeded player row (FK: rarity, cardset)
team_row -- a seeded team row 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) 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 matrix
----------- -----------
@ -30,8 +30,8 @@ Test matrix
test_list_pagination -- page/per_page params slice results correctly 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_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_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_id_resolves_player -- card_id joins Card -> Player/Team -> RefractorCardState
test_get_card_404_no_state -- card with no EvolutionCardState returns 404 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_duplicate_cards_share_state -- two cards same player+team return the same state row
test_auth_required -- missing token returns 401 on both endpoints test_auth_required -- missing token returns 401 on both endpoints
""" """
@ -63,7 +63,7 @@ def seeded_data(pg_conn):
Insertion order respects FK dependencies: Insertion order respects FK dependencies:
rarity -> cardset -> player rarity -> cardset -> player
pack_type (needs cardset) -> pack (needs team + pack_type) -> card pack_type (needs cardset) -> pack (needs team + pack_type) -> card
evolution_track -> evolution_card_state refractor_track -> refractor_card_state
""" """
cur = pg_conn.cursor() cur = pg_conn.cursor()
@ -130,7 +130,7 @@ def seeded_data(pg_conn):
# Evolution tracks # Evolution tracks
cur.execute( cur.execute(
""" """
INSERT INTO evolution_track (name, card_type, formula, INSERT INTO refractor_track (name, card_type, formula,
t1_threshold, t2_threshold, t1_threshold, t2_threshold,
t3_threshold, t4_threshold) t3_threshold, t4_threshold)
VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896) VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896)
@ -142,7 +142,7 @@ def seeded_data(pg_conn):
cur.execute( cur.execute(
""" """
INSERT INTO evolution_track (name, card_type, formula, INSERT INTO refractor_track (name, card_type, formula,
t1_threshold, t2_threshold, t1_threshold, t2_threshold,
t3_threshold, t4_threshold) t3_threshold, t4_threshold)
VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240) 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 # Batter player at tier 1, value 87.5
cur.execute( cur.execute(
""" """
INSERT INTO evolution_card_state INSERT INTO refractor_card_state
(player_id, team_id, track_id, current_tier, current_value, (player_id, team_id, track_id, current_tier, current_value,
fully_evolved, last_evaluated_at) fully_evolved, last_evaluated_at)
VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z') 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 # 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( cur.execute(
"DELETE FROM card WHERE id = ANY(%s)", "DELETE FROM card WHERE id = ANY(%s)",
([card_id, card2_id, card_no_state_id],), ([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 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 pack_type WHERE id = %s", (pack_type_id,))
cur.execute( cur.execute(
"DELETE FROM evolution_track WHERE id = ANY(%s)", "DELETE FROM refractor_track WHERE id = ANY(%s)",
([batter_track_id, sp_track_id],), ([batter_track_id, sp_track_id],),
) )
cur.execute( cur.execute(
@ -315,15 +315,15 @@ def test_list_team_evolutions(client, seeded_data):
def test_list_filter_by_card_type(client, seeded_data, pg_conn): 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. """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). 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() cur = pg_conn.cursor()
# Add a state for the sp player so we have two types in this team # Add a state for the sp player so we have two types in this team
cur.execute( cur.execute(
""" """
INSERT INTO evolution_card_state INSERT INTO refractor_card_state
(player_id, team_id, track_id, current_tier, current_value, fully_evolved) (player_id, team_id, track_id, current_tier, current_value, fully_evolved)
VALUES (%s, %s, %s, 0, 0.0, false) VALUES (%s, %s, %s, 0, 0.0, false)
RETURNING id RETURNING id
@ -352,7 +352,7 @@ def test_list_filter_by_card_type(client, seeded_data, pg_conn):
assert sp_data["count"] == 1 assert sp_data["count"] == 1
assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"] assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"]
finally: 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() pg_conn.commit()
@ -368,7 +368,7 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn):
# Advance to tier 2 # Advance to tier 2
cur.execute( 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"],), (seeded_data["state_id"],),
) )
pg_conn.commit() pg_conn.commit()
@ -391,7 +391,7 @@ def test_list_filter_by_tier(client, seeded_data, pg_conn):
assert t2_data["items"][0]["current_tier"] == 2 assert t2_data["items"][0]["current_tier"] == 2
finally: finally:
cur.execute( 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"],), (seeded_data["state_id"],),
) )
pg_conn.commit() pg_conn.commit()
@ -408,7 +408,7 @@ def test_list_pagination(client, seeded_data, pg_conn):
cur = pg_conn.cursor() cur = pg_conn.cursor()
cur.execute( cur.execute(
""" """
INSERT INTO evolution_card_state INSERT INTO refractor_card_state
(player_id, team_id, track_id, current_tier, current_value, fully_evolved) (player_id, team_id, track_id, current_tier, current_value, fully_evolved)
VALUES (%s, %s, %s, 0, 0.0, false) VALUES (%s, %s, %s, 0, 0.0, false)
RETURNING id RETURNING id
@ -441,18 +441,18 @@ def test_list_pagination(client, seeded_data, pg_conn):
assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"] assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"]
finally: 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() pg_conn.commit()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests: GET /api/v2/evolution/cards/{card_id} # Tests: GET /api/v2/refractor/cards/{card_id}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@_skip_no_pg @_skip_no_pg
def test_get_card_state_shape(client, seeded_data): 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: Verifies the full response envelope:
player_id, team_id, current_tier, current_value, fully_evolved, 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. with id, name, card_type, formula, and t1-t4 thresholds.
""" """
card_id = seeded_data["card_id"] 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
@ -505,29 +505,29 @@ def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
# Advance to tier 2 # Advance to tier 2
cur.execute( 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() pg_conn.commit()
try: 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.status_code == 200
assert resp.json()["next_threshold"] == 448 # t3_threshold assert resp.json()["next_threshold"] == 448 # t3_threshold
# Advance to tier 4 (fully evolved) # Advance to tier 4 (fully evolved)
cur.execute( 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", "WHERE id = %s",
(state_id,), (state_id,),
) )
pg_conn.commit() 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.status_code == 200
assert resp2.json()["next_threshold"] is None assert resp2.json()["next_threshold"] is None
finally: finally:
cur.execute( 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", "WHERE id = %s",
(state_id,), (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): def test_get_card_id_resolves_player(client, seeded_data):
"""card_id is resolved via the Card table to obtain (player_id, team_id). """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. Verifies that card_id correctly maps to the right player's evolution state.
""" """
card_id = seeded_data["card_id"] 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["player_id"] == seeded_data["player_id"] 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 @_skip_no_pg
def test_get_card_404_no_state(client, seeded_data): 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 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. return 404, not 500 or an empty response.
""" """
card_id = seeded_data["card_no_state_id"] 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 assert resp.status_code == 404
@_skip_no_pg @_skip_no_pg
def test_duplicate_cards_share_state(client, seeded_data): 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 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 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"] card1_id = seeded_data["card_id"]
card2_id = seeded_data["card2_id"] card2_id = seeded_data["card2_id"]
resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER) resp1 = client.get(f"/api/v2/refractor/cards/{card1_id}", headers=AUTH_HEADER)
resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER) resp2 = client.get(f"/api/v2/refractor/cards/{card2_id}", headers=AUTH_HEADER)
assert resp1.status_code == 200 assert resp1.status_code == 200
assert resp2.status_code == 200 assert resp2.status_code == 200
@ -597,7 +597,7 @@ def test_auth_required(client, seeded_data):
Verifies that the valid_token dependency is enforced on: Verifies that the valid_token dependency is enforced on:
GET /api/v2/teams/{id}/evolutions GET /api/v2/teams/{id}/evolutions
GET /api/v2/evolution/cards/{id} GET /api/v2/refractor/cards/{id}
""" """
team_id = seeded_data["team_id"] team_id = seeded_data["team_id"]
card_id = seeded_data["card_id"] card_id = seeded_data["card_id"]
@ -605,5 +605,5 @@ def test_auth_required(client, seeded_data):
resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions") resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions")
assert resp_list.status_code == 401 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 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: Tests cover:
GET /api/v2/evolution/tracks GET /api/v2/refractor/tracks
GET /api/v2/evolution/tracks/{track_id} GET /api/v2/refractor/tracks/{track_id}
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and 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. Tests auto-skip when POSTGRES_HOST is not set.
Test data is inserted via psycopg2 before the test module runs and deleted 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: for name, card_type, formula, t1, t2, t3, t4 in _SEED_TRACKS:
cur.execute( cur.execute(
""" """
INSERT INTO evolution_track INSERT INTO refractor_track
(name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold) (name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold)
VALUES (%s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (card_type) DO UPDATE SET ON CONFLICT (card_type) DO UPDATE SET
@ -62,7 +62,7 @@ def seeded_tracks(pg_conn):
ids.append(cur.fetchone()[0]) ids.append(cur.fetchone()[0])
pg_conn.commit() pg_conn.commit()
yield ids 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() 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 After seeding batter/sp/rp, the table should have exactly those three
rows (no other tracks are inserted by other test modules). 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["count"] == 3 assert data["count"] == 3
@ -92,7 +92,7 @@ def test_list_tracks_returns_count_3(client, seeded_tracks):
@_skip_no_pg @_skip_no_pg
def test_filter_by_card_type(client, seeded_tracks): def test_filter_by_card_type(client, seeded_tracks):
"""card_type=sp filter returns exactly 1 track with card_type 'sp'.""" """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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["count"] == 1 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): def test_get_single_track_with_thresholds(client, seeded_tracks):
"""GET /tracks/{id} returns a track dict with formula and t1-t4 thresholds.""" """GET /tracks/{id} returns a track dict with formula and t1-t4 thresholds."""
track_id = seeded_tracks[0] # batter 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 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["card_type"] == "batter" assert data["card_type"] == "batter"
@ -117,16 +117,16 @@ def test_get_single_track_with_thresholds(client, seeded_tracks):
@_skip_no_pg @_skip_no_pg
def test_404_for_nonexistent_track(client, seeded_tracks): def test_404_for_nonexistent_track(client, seeded_tracks):
"""GET /tracks/999999 returns 404 when the track does not exist.""" """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 assert resp.status_code == 404
@_skip_no_pg @_skip_no_pg
def test_auth_required(client, seeded_tracks): def test_auth_required(client, seeded_tracks):
"""Requests without a Bearer token return 401 for both endpoints.""" """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 assert resp_list.status_code == 401
track_id = seeded_tracks[0] 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 assert resp_single.status_code == 401