Pure functions for computing boosted card ratings when a player reaches a new Refractor tier. Batter boost applies fixed +0.5 to four offensive columns per tier; pitcher boost uses a 1.5 TB-budget priority algorithm. Both preserve the 108-sum invariant. - Create refractor_boost.py with apply_batter_boost, apply_pitcher_boost, and compute_variant_hash (Decimal arithmetic, zero-floor truncation) - Add RefractorBoostAudit model, Card.variant, BattingCard/PitchingCard image_url, RefractorCardState.variant fields to db_engine.py - Add migration SQL for refractor_card_state.variant column and refractor_boost_audit table (JSONB, UNIQUE constraint, transactional) - 26 unit tests covering 108-sum invariant, deltas, truncation, TB accounting, determinism, x-check protection, and variant hash behavior Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
219 lines
6.2 KiB
Python
219 lines
6.2 KiB
Python
"""
|
|
Shared test fixtures for the Paper Dynasty database test suite.
|
|
|
|
Uses in-memory SQLite with foreign_keys pragma enabled. Each test
|
|
gets a fresh set of tables via the setup_test_db fixture (autouse).
|
|
|
|
All models are bound to the in-memory database before table creation
|
|
so that no connection to the real storage/pd_master.db occurs during
|
|
tests.
|
|
"""
|
|
|
|
import os
|
|
import pytest
|
|
import psycopg2
|
|
from peewee import SqliteDatabase
|
|
|
|
# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION
|
|
# flag is True. This prevents db_engine.py from calling create_tables()
|
|
# against the real storage/pd_master.db during import — those calls would
|
|
# fail if indexes already exist and would also contaminate the dev database.
|
|
# The PooledPostgresqlDatabase object is created but never actually connects
|
|
# because our fixture rebinds all models to an in-memory SQLite db before
|
|
# any query is executed.
|
|
os.environ["DATABASE_TYPE"] = "postgresql"
|
|
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
|
# without raising a configuration error (it will not actually be used).
|
|
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
|
|
|
from app.db_engine import (
|
|
Rarity,
|
|
Event,
|
|
Cardset,
|
|
MlbPlayer,
|
|
Player,
|
|
Team,
|
|
PackType,
|
|
Pack,
|
|
Card,
|
|
Roster,
|
|
RosterSlot,
|
|
StratGame,
|
|
StratPlay,
|
|
Decision,
|
|
BattingSeasonStats,
|
|
PitchingSeasonStats,
|
|
ProcessedGame,
|
|
BattingCard,
|
|
PitchingCard,
|
|
RefractorTrack,
|
|
RefractorCardState,
|
|
RefractorTierBoost,
|
|
RefractorCosmetic,
|
|
RefractorBoostAudit,
|
|
ScoutOpportunity,
|
|
ScoutClaim,
|
|
)
|
|
|
|
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
|
|
|
# All models in dependency order (parents before children) so that
|
|
# create_tables and drop_tables work without FK violations.
|
|
_TEST_MODELS = [
|
|
Rarity,
|
|
Event,
|
|
Cardset,
|
|
MlbPlayer,
|
|
Player,
|
|
Team,
|
|
PackType,
|
|
Pack,
|
|
Card,
|
|
Roster,
|
|
RosterSlot,
|
|
StratGame,
|
|
StratPlay,
|
|
Decision,
|
|
BattingSeasonStats,
|
|
PitchingSeasonStats,
|
|
ProcessedGame,
|
|
ScoutOpportunity,
|
|
ScoutClaim,
|
|
RefractorTrack,
|
|
RefractorCardState,
|
|
RefractorTierBoost,
|
|
RefractorCosmetic,
|
|
BattingCard,
|
|
PitchingCard,
|
|
RefractorBoostAudit,
|
|
]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_test_db():
|
|
"""Bind all models to in-memory SQLite and create tables.
|
|
|
|
The fixture is autouse so every test automatically gets a fresh,
|
|
isolated database schema without needing to request it explicitly.
|
|
Tables are dropped in reverse dependency order after each test to
|
|
keep the teardown clean and to catch any accidental FK reference
|
|
direction bugs early.
|
|
"""
|
|
_test_db.bind(_TEST_MODELS)
|
|
_test_db.connect()
|
|
_test_db.create_tables(_TEST_MODELS)
|
|
yield _test_db
|
|
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
|
_test_db.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimal shared fixtures — create just enough data for FK dependencies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def rarity():
|
|
"""A single Common rarity row used as FK seed for Player rows."""
|
|
return Rarity.create(value=1, name="Common", color="#ffffff")
|
|
|
|
|
|
@pytest.fixture
|
|
def player(rarity):
|
|
"""A minimal Player row with all required (non-nullable) columns filled.
|
|
|
|
Player.p_name is the real column name (not 'name'). All FK and
|
|
non-nullable varchar fields are provided so SQLite's NOT NULL
|
|
constraints are satisfied even with foreign_keys=ON.
|
|
"""
|
|
cardset = Cardset.create(
|
|
name="Test Set",
|
|
description="Test cardset",
|
|
total_cards=100,
|
|
)
|
|
return Player.create(
|
|
p_name="Test Player",
|
|
rarity=rarity,
|
|
cardset=cardset,
|
|
set_num=1,
|
|
pos_1="1B",
|
|
image="https://example.com/image.png",
|
|
mlbclub="TST",
|
|
franchise="TST",
|
|
description="A test player",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def team():
|
|
"""A minimal Team row.
|
|
|
|
Team uses abbrev/lname/sname/gmid/gmname/gsheet/wallet/team_value/
|
|
collection_value — not the 'name'/'user_id' shorthand described in
|
|
the spec, which referred to the real underlying columns by
|
|
simplified names.
|
|
"""
|
|
return Team.create(
|
|
abbrev="TST",
|
|
sname="Test",
|
|
lname="Test Team",
|
|
gmid=100000001,
|
|
gmname="testuser",
|
|
gsheet="https://docs.google.com/spreadsheets/test",
|
|
wallet=500,
|
|
team_value=1000,
|
|
collection_value=1000,
|
|
season=11,
|
|
is_ai=False,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def track():
|
|
"""A minimal RefractorTrack for batter cards."""
|
|
return RefractorTrack.create(
|
|
name="Batter Track",
|
|
card_type="batter",
|
|
formula="pa + tb * 2",
|
|
t1_threshold=37,
|
|
t2_threshold=149,
|
|
t3_threshold=448,
|
|
t4_threshold=896,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PostgreSQL integration fixture (used by test_refractor_*_api.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def pg_conn():
|
|
"""Open a psycopg2 connection to the PostgreSQL instance for integration tests.
|
|
|
|
Reads connection parameters from the standard POSTGRES_* env vars that the
|
|
CI workflow injects when a postgres service container is running. Skips the
|
|
entire session (via pytest.skip) when POSTGRES_HOST is not set, keeping
|
|
local runs clean.
|
|
|
|
The connection is shared for the whole session (scope="session") because
|
|
the integration test modules use module-scoped fixtures that rely on it;
|
|
creating a new connection per test would break those module-scoped fixtures.
|
|
|
|
Teardown: the connection is closed once all tests have finished.
|
|
"""
|
|
host = os.environ.get("POSTGRES_HOST")
|
|
if not host:
|
|
pytest.skip("POSTGRES_HOST not set — PostgreSQL integration tests skipped")
|
|
|
|
conn = psycopg2.connect(
|
|
host=host,
|
|
port=int(os.environ.get("POSTGRES_PORT", "5432")),
|
|
dbname=os.environ.get("POSTGRES_DB", "paper_dynasty"),
|
|
user=os.environ.get("POSTGRES_USER", "postgres"),
|
|
password=os.environ.get("POSTGRES_PASSWORD", ""),
|
|
)
|
|
conn.autocommit = False
|
|
yield conn
|
|
conn.close()
|