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:
parent
0b6e85fff9
commit
b7dec3f231
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
@ -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,
|
||||||
15
migrations/2026-03-23_rename_evolution_to_refractor.sql
Normal file
15
migrations/2026-03-23_rename_evolution_to_refractor.sql
Normal 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;
|
||||||
@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
@ -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")
|
||||||
|
|
||||||
@ -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",
|
||||||
@ -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}"
|
||||||
@ -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
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user