- test_evolution_models: 12 tests for EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic, and PlayerSeasonStats models - test_evolution_seed: 7 tests for seed idempotency, thresholds, formulas - test_season_stats_update: 6 tests for batting/pitching aggregation, Decision integration, double-count prevention, multi-game accumulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""
|
|
Tests for evolution-related models and PlayerSeasonStats.
|
|
|
|
Covers WP-01 acceptance criteria:
|
|
- EvolutionTrack: CRUD and unique-name constraint
|
|
- EvolutionCardState: CRUD, defaults, unique-(player,team) constraint,
|
|
and FK resolution back to EvolutionTrack
|
|
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
|
|
- EvolutionCosmetic: CRUD and unique-name constraint
|
|
- PlayerSeasonStats: CRUD with defaults, unique-(player, team, season),
|
|
and in-place stat accumulation
|
|
|
|
Each test class is self-contained: fixtures from conftest.py supply the
|
|
minimal parent rows needed to satisfy FK constraints, and every assertion
|
|
targets a single, clearly-named behaviour so failures are easy to trace.
|
|
"""
|
|
|
|
import pytest
|
|
from peewee import IntegrityError
|
|
from playhouse.shortcuts import model_to_dict
|
|
|
|
from app.db_engine import (
|
|
EvolutionCardState,
|
|
EvolutionCosmetic,
|
|
EvolutionTierBoost,
|
|
EvolutionTrack,
|
|
PlayerSeasonStats,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EvolutionTrack
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvolutionTrack:
|
|
"""Tests for the EvolutionTrack model.
|
|
|
|
EvolutionTrack defines a named progression path (formula +
|
|
tier thresholds) for a card type. The name column carries a
|
|
UNIQUE constraint so that accidental duplicates are caught at
|
|
the database level.
|
|
"""
|
|
|
|
def test_create_track(self, track):
|
|
"""Creating a track persists all fields and they round-trip correctly.
|
|
|
|
Reads back via model_to_dict (recurse=False) to verify the raw
|
|
column values, not Python-object representations, match what was
|
|
inserted.
|
|
"""
|
|
data = model_to_dict(track, recurse=False)
|
|
assert data["name"] == "Batter Track"
|
|
assert data["card_type"] == "batter"
|
|
assert data["formula"] == "pa + tb * 2"
|
|
assert data["t1_threshold"] == 37
|
|
assert data["t2_threshold"] == 149
|
|
assert data["t3_threshold"] == 448
|
|
assert data["t4_threshold"] == 896
|
|
|
|
def test_track_unique_name(self, track):
|
|
"""Inserting a second track with the same name raises IntegrityError.
|
|
|
|
The UNIQUE constraint on EvolutionTrack.name must prevent two
|
|
tracks from sharing the same identifier, as the name is used as
|
|
a human-readable key throughout the evolution system.
|
|
"""
|
|
with pytest.raises(IntegrityError):
|
|
EvolutionTrack.create(
|
|
name="Batter Track", # duplicate
|
|
card_type="sp",
|
|
formula="outs * 3",
|
|
t1_threshold=10,
|
|
t2_threshold=40,
|
|
t3_threshold=120,
|
|
t4_threshold=240,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EvolutionCardState
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvolutionCardState:
|
|
"""Tests for EvolutionCardState, which tracks per-player evolution progress.
|
|
|
|
Each row represents one card (player) owned by one team, linked to a
|
|
specific EvolutionTrack. The model records the current tier (0-4),
|
|
accumulated progress value, and whether the card is fully evolved.
|
|
"""
|
|
|
|
def test_create_card_state(self, player, team, track):
|
|
"""Creating a card state stores all fields and defaults are correct.
|
|
|
|
Defaults under test:
|
|
current_tier → 0 (fresh card, no tier unlocked yet)
|
|
current_value → 0.0 (no formula progress accumulated)
|
|
fully_evolved → False (evolution is not complete at creation)
|
|
last_evaluated_at → None (never evaluated yet)
|
|
"""
|
|
state = EvolutionCardState.create(player=player, team=team, track=track)
|
|
|
|
fetched = EvolutionCardState.get_by_id(state.id)
|
|
assert fetched.player_id == player.player_id
|
|
assert fetched.team_id == team.id
|
|
assert fetched.track_id == track.id
|
|
assert fetched.current_tier == 0
|
|
assert fetched.current_value == 0.0
|
|
assert fetched.fully_evolved is False
|
|
assert fetched.last_evaluated_at is None
|
|
|
|
def test_card_state_unique_player_team(self, player, team, track):
|
|
"""A second card state for the same (player, team) pair raises IntegrityError.
|
|
|
|
The unique index on (player, team) enforces that each player card
|
|
has at most one evolution state per team roster slot, preventing
|
|
duplicate evolution progress rows for the same physical card.
|
|
"""
|
|
EvolutionCardState.create(player=player, team=team, track=track)
|
|
with pytest.raises(IntegrityError):
|
|
EvolutionCardState.create(player=player, team=team, track=track)
|
|
|
|
def test_card_state_fk_track(self, player, team, track):
|
|
"""Accessing card_state.track returns the original EvolutionTrack instance.
|
|
|
|
This confirms the FK is correctly wired and that Peewee resolves
|
|
the relationship, returning an object with the same primary key and
|
|
name as the track used during creation.
|
|
"""
|
|
state = EvolutionCardState.create(player=player, team=team, track=track)
|
|
fetched = EvolutionCardState.get_by_id(state.id)
|
|
resolved_track = fetched.track
|
|
assert resolved_track.id == track.id
|
|
assert resolved_track.name == "Batter Track"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EvolutionTierBoost
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvolutionTierBoost:
|
|
"""Tests for EvolutionTierBoost, the per-tier stat/rating bonus table.
|
|
|
|
Each row maps a (track, tier) combination to a single boost — the
|
|
specific stat or rating column to buff and by how much. The four-
|
|
column unique constraint prevents double-booking the same boost slot.
|
|
"""
|
|
|
|
def test_create_tier_boost(self, track):
|
|
"""Creating a boost row persists all fields accurately.
|
|
|
|
Verifies boost_type, boost_target, and boost_value are stored
|
|
and retrieved without modification.
|
|
"""
|
|
boost = EvolutionTierBoost.create(
|
|
track=track,
|
|
tier=1,
|
|
boost_type="rating",
|
|
boost_target="contact_vl",
|
|
boost_value=1.5,
|
|
)
|
|
fetched = EvolutionTierBoost.get_by_id(boost.id)
|
|
assert fetched.track_id == track.id
|
|
assert fetched.tier == 1
|
|
assert fetched.boost_type == "rating"
|
|
assert fetched.boost_target == "contact_vl"
|
|
assert fetched.boost_value == 1.5
|
|
|
|
def test_tier_boost_unique_constraint(self, track):
|
|
"""Duplicate (track, tier, boost_type, boost_target) raises IntegrityError.
|
|
|
|
The four-column unique index ensures that a single boost slot
|
|
(e.g. Tier-1 contact_vl rating) cannot be defined twice for the
|
|
same track, which would create ambiguity during evolution evaluation.
|
|
"""
|
|
EvolutionTierBoost.create(
|
|
track=track,
|
|
tier=2,
|
|
boost_type="rating",
|
|
boost_target="power_vr",
|
|
boost_value=2.0,
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
EvolutionTierBoost.create(
|
|
track=track,
|
|
tier=2,
|
|
boost_type="rating",
|
|
boost_target="power_vr",
|
|
boost_value=3.0, # different value, same identity columns
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EvolutionCosmetic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvolutionCosmetic:
|
|
"""Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers.
|
|
|
|
Cosmetics are purely visual rewards (frames, badges, themes) that a
|
|
card unlocks when it reaches a required tier. The name column is
|
|
the stable identifier and carries a UNIQUE constraint.
|
|
"""
|
|
|
|
def test_create_cosmetic(self):
|
|
"""Creating a cosmetic persists all fields correctly.
|
|
|
|
Verifies all columns including optional ones (css_class, asset_url)
|
|
are stored and retrieved.
|
|
"""
|
|
cosmetic = EvolutionCosmetic.create(
|
|
name="Gold Frame",
|
|
tier_required=2,
|
|
cosmetic_type="frame",
|
|
css_class="evo-frame-gold",
|
|
asset_url="https://cdn.example.com/frames/gold.png",
|
|
)
|
|
fetched = EvolutionCosmetic.get_by_id(cosmetic.id)
|
|
assert fetched.name == "Gold Frame"
|
|
assert fetched.tier_required == 2
|
|
assert fetched.cosmetic_type == "frame"
|
|
assert fetched.css_class == "evo-frame-gold"
|
|
assert fetched.asset_url == "https://cdn.example.com/frames/gold.png"
|
|
|
|
def test_cosmetic_unique_name(self):
|
|
"""Inserting a second cosmetic with the same name raises IntegrityError.
|
|
|
|
The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate
|
|
cosmetic definitions that could cause ambiguous tier unlock lookups.
|
|
"""
|
|
EvolutionCosmetic.create(
|
|
name="Silver Badge",
|
|
tier_required=1,
|
|
cosmetic_type="badge",
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
EvolutionCosmetic.create(
|
|
name="Silver Badge", # duplicate
|
|
tier_required=3,
|
|
cosmetic_type="badge",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PlayerSeasonStats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPlayerSeasonStats:
|
|
"""Tests for PlayerSeasonStats, the per-season accumulation table.
|
|
|
|
Each row aggregates game-by-game batting and pitching stats for one
|
|
player on one team in one season. The three-column unique constraint
|
|
prevents double-counting and ensures a single authoritative row for
|
|
each (player, team, season) combination.
|
|
"""
|
|
|
|
def test_create_season_stats(self, player, team):
|
|
"""Creating a stats row with explicit values stores everything correctly.
|
|
|
|
Also verifies the integer stat defaults (all 0) for columns that
|
|
are not provided, which is the initial state before any games are
|
|
processed.
|
|
"""
|
|
stats = PlayerSeasonStats.create(
|
|
player=player,
|
|
team=team,
|
|
season=11,
|
|
games_batting=5,
|
|
pa=20,
|
|
ab=18,
|
|
hits=6,
|
|
doubles=1,
|
|
triples=0,
|
|
hr=2,
|
|
bb=2,
|
|
hbp=0,
|
|
so=4,
|
|
rbi=5,
|
|
runs=3,
|
|
sb=1,
|
|
cs=0,
|
|
)
|
|
fetched = PlayerSeasonStats.get_by_id(stats.id)
|
|
assert fetched.player_id == player.player_id
|
|
assert fetched.team_id == team.id
|
|
assert fetched.season == 11
|
|
assert fetched.games_batting == 5
|
|
assert fetched.pa == 20
|
|
assert fetched.hits == 6
|
|
assert fetched.hr == 2
|
|
# Pitching fields were not set — confirm default zero values
|
|
assert fetched.games_pitching == 0
|
|
assert fetched.outs == 0
|
|
assert fetched.wins == 0
|
|
assert fetched.saves == 0
|
|
# Nullable meta fields
|
|
assert fetched.last_game is None
|
|
assert fetched.last_updated_at is None
|
|
|
|
def test_season_stats_unique_constraint(self, player, team):
|
|
"""A second row for the same (player, team, season) raises IntegrityError.
|
|
|
|
The unique index on these three columns guarantees that each
|
|
player-team-season combination has exactly one accumulation row,
|
|
preventing duplicate stat aggregation that would inflate totals.
|
|
"""
|
|
PlayerSeasonStats.create(player=player, team=team, season=11)
|
|
with pytest.raises(IntegrityError):
|
|
PlayerSeasonStats.create(player=player, team=team, season=11)
|
|
|
|
def test_season_stats_increment(self, player, team):
|
|
"""Manually incrementing hits on an existing row persists the change.
|
|
|
|
Simulates the common pattern used by the stats accumulator:
|
|
fetch the row, add the game delta, save. Verifies that save()
|
|
writes back to the database and that subsequent reads reflect the
|
|
updated value.
|
|
"""
|
|
stats = PlayerSeasonStats.create(
|
|
player=player,
|
|
team=team,
|
|
season=11,
|
|
hits=10,
|
|
)
|
|
stats.hits += 3
|
|
stats.save()
|
|
|
|
refreshed = PlayerSeasonStats.get_by_id(stats.id)
|
|
assert refreshed.hits == 13
|