Merge pull request 'feat: add /dev refractor-test integration test command' (#161) from feat/refractor-integration-test into main
All checks were successful
Build Docker Image / build (push) Successful in 3m14s

This commit is contained in:
cal 2026-04-10 02:23:21 +00:00
commit 662c1c5448
6 changed files with 1185 additions and 1 deletions

3
.gitignore vendored
View File

@ -137,4 +137,5 @@ storage/paper-dynasty-service-creds.json
**.db
**/htmlcov
.vscode/**
.claude/**
.claude/**
.worktrees/

389
cogs/dev_tools.py Normal file
View File

@ -0,0 +1,389 @@
"""Dev-only tools for testing Paper Dynasty systems.
This cog is only loaded when DATABASE != prod. It provides commands
for integration testing that create and clean up synthetic test data.
"""
import logging
from datetime import date
import discord
from discord import app_commands
from discord.ext import commands
from api_calls import db_delete, db_get, db_post
from helpers.constants import PD_SEASON
from helpers.main import get_team_by_owner
from helpers.refractor_constants import TIER_NAMES
from helpers.refractor_test_data import (
build_batter_plays,
build_decision_data,
build_game_data,
build_pitcher_plays,
calculate_plays_needed,
)
CURRENT_SEASON = PD_SEASON
logger = logging.getLogger(__name__)
class CleanupView(discord.ui.View):
"""Post-test buttons to clean up or keep synthetic game data."""
def __init__(
self, owner_id: int, game_id: int, embed: discord.Embed, timeout: float = 300.0
):
super().__init__(timeout=timeout)
self.owner_id = owner_id
self.game_id = game_id
self.embed = embed
@discord.ui.button(
label="Clean Up Test Data", style=discord.ButtonStyle.danger, emoji="🧹"
)
async def cleanup_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.owner_id:
return
try:
await db_delete("decisions/game", self.game_id)
await db_delete("plays/game", self.game_id)
await db_delete("games", self.game_id)
self.embed.add_field(
name="",
value=f"🧹 Test data cleaned up (game #{self.game_id} removed)",
inline=False,
)
except Exception as e:
self.embed.add_field(
name="",
value=f"❌ Cleanup failed: {e}",
inline=False,
)
self.clear_items()
await interaction.response.edit_message(embed=self.embed, view=self)
self.stop()
@discord.ui.button(
label="Keep Test Data", style=discord.ButtonStyle.secondary, emoji="📌"
)
async def keep_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.owner_id:
return
self.embed.add_field(
name="",
value=f"📌 Test data kept (game #{self.game_id})",
inline=False,
)
self.clear_items()
await interaction.response.edit_message(embed=self.embed, view=self)
self.stop()
async def on_timeout(self):
# Note: clear_items() updates the local view but cannot push to Discord
# without a message reference. Buttons will become unresponsive after timeout.
self.clear_items()
class DevToolsCog(commands.Cog):
"""Dev-only commands for integration testing.
Only loaded when DATABASE env var is not 'prod'.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
group_dev = app_commands.Group(name="dev", description="Dev-only testing tools")
@group_dev.command(
name="refractor-test", description="Run refractor integration test on a card"
)
@app_commands.describe(card_id="The batting or pitching card ID to test")
async def refractor_test(self, interaction: discord.Interaction, card_id: int):
await interaction.response.defer()
# --- Phase 1: Setup ---
# Look up card (try batting first, then pitching)
card = await db_get("battingcards", object_id=card_id)
card_type_key = "batting"
if card is None:
card = await db_get("pitchingcards", object_id=card_id)
card_type_key = "pitching"
if card is None:
await interaction.edit_original_response(
content=f"❌ Card #{card_id} not found (checked batting and pitching)."
)
return
player_id = card["player"]["id"]
player_name = card["player"]["p_name"]
team_id = card.get("team_id") or card["player"].get("team_id")
if team_id is None:
team = await get_team_by_owner(interaction.user.id)
if team is None:
await interaction.edit_original_response(
content="❌ Could not determine team ID. You must own a team."
)
return
team_id = team["id"]
# Fetch refractor state
refractor_data = await db_get(
"refractor/cards",
params=[("team_id", team_id), ("limit", 100)],
)
# Find this player's entry
card_state = None
if refractor_data and refractor_data.get("items"):
for item in refractor_data["items"]:
if item["player_id"] == player_id:
card_state = item
break
# Determine current state and thresholds
if card_state:
current_tier = card_state["current_tier"]
current_value = card_state["current_value"]
card_type = card_state["track"]["card_type"]
next_threshold = card_state["next_threshold"]
else:
current_tier = 0
current_value = 0
card_type = "batter" if card_type_key == "batting" else "sp"
next_threshold = (
37 if card_type == "batter" else (10 if card_type == "sp" else 3)
)
if current_tier >= 4:
await interaction.edit_original_response(
content=f"⚠️ {player_name} is already at T4 Superfractor — fully evolved."
)
return
# Calculate plan
gap = max(0, next_threshold - current_value)
plan = calculate_plays_needed(gap, card_type)
# Find an opposing player
if card_type == "batter":
opposing_cards = await db_get(
"pitchingcards",
params=[("team_id", team_id), ("variant", 0)],
)
else:
opposing_cards = await db_get(
"battingcards",
params=[("team_id", team_id), ("variant", 0)],
)
if not opposing_cards or not opposing_cards.get("cards"):
await interaction.edit_original_response(
content=f"❌ No opposing {'pitcher' if card_type == 'batter' else 'batter'} cards found on team {team_id}."
)
return
opposing_player_id = opposing_cards["cards"][0]["player"]["id"]
# Build and send initial embed
tier_name = TIER_NAMES.get(current_tier, f"T{current_tier}")
next_tier_name = TIER_NAMES.get(current_tier + 1, f"T{current_tier + 1}")
play_desc = (
f"{plan['num_plays']} HR plays"
if card_type == "batter"
else f"{plan['num_plays']} K plays"
)
embed = discord.Embed(
title="Refractor Integration Test",
color=0x3498DB,
)
embed.add_field(
name="Setup",
value=(
f"**Player:** {player_name} (card #{card_id})\n"
f"**Type:** {card_type_key.title()}\n"
f"**Current:** T{current_tier} {tier_name} → **Target:** T{current_tier + 1} {next_tier_name}\n"
f"**Value:** {current_value} / {next_threshold} (need {gap} more)\n"
f"**Plan:** {play_desc} (+{plan['total_value']:.0f} value)"
),
inline=False,
)
embed.add_field(name="", value="⏳ Executing...", inline=False)
await interaction.edit_original_response(embed=embed)
# --- Phase 2: Execute ---
await self._execute_refractor_test(
interaction=interaction,
embed=embed,
player_id=player_id,
team_id=team_id,
card_type=card_type,
card_type_key=card_type_key,
opposing_player_id=opposing_player_id,
num_plays=plan["num_plays"],
)
async def _execute_refractor_test(
self,
interaction: discord.Interaction,
embed: discord.Embed,
player_id: int,
team_id: int,
card_type: str,
card_type_key: str,
opposing_player_id: int,
num_plays: int,
):
"""Execute the refractor integration test chain.
Creates synthetic game data, runs the real refractor pipeline,
and reports pass/fail at each step. Stops on first failure.
"""
results = []
game_id = None
# Remove the "Executing..." field
if len(embed.fields) > 1:
embed.remove_field(len(embed.fields) - 1)
# Helper to update the embed with current results
async def update_embed(view: discord.ui.View | None = None):
results_text = "\n".join(results)
# Remove old results field if present, add new one
while len(embed.fields) > 1:
embed.remove_field(len(embed.fields) - 1)
embed.add_field(name="Results", value=results_text, inline=False)
if view is not None:
await interaction.edit_original_response(embed=embed, view=view)
else:
await interaction.edit_original_response(embed=embed)
try:
# Step 1: Create game
game_data = build_game_data(team_id=team_id, season=CURRENT_SEASON)
game_resp = await db_post("games", payload=game_data)
game_id = game_resp["id"]
results.append(f"✅ Game created (#{game_id})")
await update_embed()
except Exception as e:
results.append(f"❌ Game creation failed: {e}")
await update_embed()
return
try:
# Step 2: Create plays
if card_type == "batter":
plays = build_batter_plays(
game_id, player_id, team_id, opposing_player_id, num_plays
)
else:
plays = build_pitcher_plays(
game_id, player_id, team_id, opposing_player_id, num_plays
)
await db_post("plays", payload={"plays": plays})
results.append(f"{num_plays} plays inserted")
await update_embed()
except Exception as e:
results.append(f"❌ Play insertion failed: {e}")
await update_embed(view=CleanupView(interaction.user.id, game_id, embed))
return
try:
# Step 3: Create pitcher decision
pitcher_id = opposing_player_id if card_type == "batter" else player_id
decision_data = build_decision_data(
game_id, pitcher_id, team_id, CURRENT_SEASON
)
await db_post("decisions", payload=decision_data)
results.append("✅ Pitcher decision inserted")
await update_embed()
except Exception as e:
results.append(f"❌ Decision insertion failed: {e}")
await update_embed(view=CleanupView(interaction.user.id, game_id, embed))
return
try:
# Step 4: Update season stats
stats_resp = await db_post(f"season-stats/update-game/{game_id}")
if stats_resp and stats_resp.get("skipped"):
results.append("⚠️ Season stats skipped (already processed)")
else:
updated = stats_resp.get("updated", "?") if stats_resp else "?"
results.append(f"✅ Season stats updated ({updated} players)")
await update_embed()
except Exception as e:
results.append(f"❌ Season stats update failed: {e}")
results.append("⏭️ Skipped: evaluate-game (depends on season stats)")
await update_embed(view=CleanupView(interaction.user.id, game_id, embed))
return
try:
# Step 5: Evaluate refractor
eval_resp = await db_post(f"refractor/evaluate-game/{game_id}")
tier_ups = eval_resp.get("tier_ups", []) if eval_resp else []
if tier_ups:
for tu in tier_ups:
old_t = tu.get("old_tier", "?")
new_t = tu.get("new_tier", "?")
variant = tu.get("variant_created", "")
results.append(f"✅ Tier-up detected! T{old_t} → T{new_t}")
if variant:
results.append(f"✅ Variant card created (variant: {variant})")
else:
evaluated = eval_resp.get("evaluated", "?") if eval_resp else "?"
results.append(f"⚠️ No tier-up detected (evaluated {evaluated} cards)")
await update_embed()
except Exception as e:
results.append(f"❌ Evaluate-game failed: {e}")
await update_embed(view=CleanupView(interaction.user.id, game_id, embed))
return
# Step 6: Trigger card render (if tier-up)
if tier_ups:
for tu in tier_ups:
variant = tu.get("variant_created")
if not variant:
continue
try:
today = date.today().isoformat()
render_resp = await db_get(
f"players/{tu['player_id']}/{card_type_key}card/{today}/{variant}",
none_okay=True,
)
if render_resp:
results.append("✅ Card rendered + S3 upload triggered")
img_url = (
render_resp
if isinstance(render_resp, str)
else render_resp.get("image_url")
)
if (
img_url
and isinstance(img_url, str)
and img_url.startswith("http")
):
embed.set_image(url=img_url)
else:
results.append(
"⚠️ Card render returned no data (may still be processing)"
)
except Exception as e:
results.append(f"⚠️ Card render failed (non-fatal): {e}")
# Final update with cleanup buttons
await update_embed(view=CleanupView(interaction.user.id, game_id, embed))
async def setup(bot: commands.Bot):
await bot.add_cog(DevToolsCog(bot))

View File

@ -0,0 +1,213 @@
"""Pure helper functions for the /dev refractor-test command.
Builds synthetic game data to push a card over its next refractor
tier threshold with the minimum number of plays.
"""
import math
# Batter: value = PA + (TB * 2). A HR play: PA=1, TB=4 → value = 1 + 8 = 9
BATTER_VALUE_PER_PLAY = 9
# Pitcher: value = IP + K. A K play: outs=1 (IP=1/3), K=1 → value = 1/3 + 1 = 4/3
PITCHER_VALUE_PER_PLAY = 4 / 3
def calculate_plays_needed(gap: int, card_type: str) -> dict:
"""Calculate the number of synthetic plays needed to close a refractor gap.
Args:
gap: Points needed to reach the next tier threshold.
A gap of 0 means the card is exactly at threshold we still
need 1 play to push past it.
card_type: One of "batter", "sp", "rp".
Returns:
dict with keys:
num_plays: int number of plays to create
total_value: float total refractor value those plays will add
value_per_play: float value each play contributes
"""
if card_type == "batter":
value_per_play = BATTER_VALUE_PER_PLAY
else:
value_per_play = PITCHER_VALUE_PER_PLAY
num_plays = max(1, math.ceil(gap / value_per_play))
total_value = num_plays * value_per_play
return {
"num_plays": num_plays,
"total_value": total_value,
"value_per_play": value_per_play,
}
def build_game_data(team_id: int, season: int) -> dict:
"""Build a minimal game record for refractor testing.
Creates a self-play game (team vs itself) with game_type='test'.
All score and ranking fields are zeroed; short_game=True avoids
full simulation overhead when this record is posted to the API.
"""
return {
"season": season,
"game_type": "test",
"away_team_id": team_id,
"home_team_id": team_id,
"week": 1,
"away_score": 0,
"home_score": 0,
"away_team_value": 0,
"home_team_value": 0,
"away_team_ranking": 0,
"home_team_ranking": 0,
"ranked": False,
"short_game": True,
"forfeit": False,
}
def build_batter_plays(
game_id: int,
batter_id: int,
team_id: int,
pitcher_id: int,
num_plays: int,
) -> list[dict]:
"""Build a list of synthetic solo-HR batter plays for refractor testing.
Each play is a solo home run (PA=1, AB=1, H=1, HR=1, R=1, RBI=1).
Structural fields use safe defaults so the batch is accepted by the
plays API endpoint without requiring real game context. play_num is
sequential starting at 1.
Args:
game_id: ID of the game these plays belong to.
batter_id: Card/player ID of the batter receiving credit.
team_id: Team ID used for both batter_team_id and pitcher_team_id
(self-play game).
pitcher_id: Card/player ID of the opposing pitcher.
num_plays: Number of HR plays to generate.
Returns:
List of play dicts, one per home run.
"""
plays = []
for i in range(num_plays):
plays.append(
{
"game_id": game_id,
"play_num": i + 1,
"batter_id": batter_id,
"batter_team_id": team_id,
"pitcher_id": pitcher_id,
"pitcher_team_id": team_id,
"pa": 1,
"ab": 1,
"hit": 1,
"homerun": 1,
"run": 1,
"rbi": 1,
"on_base_code": "000",
"inning_half": "bot",
"inning_num": 1,
"batting_order": 1,
"starting_outs": 0,
"away_score": 0,
"home_score": 0,
}
)
return plays
def build_pitcher_plays(
game_id: int,
pitcher_id: int,
team_id: int,
batter_id: int,
num_plays: int,
) -> list[dict]:
"""Build a list of synthetic strikeout pitcher plays for refractor testing.
Each play is a strikeout (PA=1, AB=1, SO=1, outs=1, hit=0, homerun=0).
Structural fields use the same safe defaults as build_batter_plays.
play_num is sequential starting at 1.
Args:
game_id: ID of the game these plays belong to.
pitcher_id: Card/player ID of the pitcher receiving credit.
team_id: Team ID used for both pitcher_team_id and batter_team_id
(self-play game).
batter_id: Card/player ID of the opposing batter.
num_plays: Number of strikeout plays to generate.
Returns:
List of play dicts, one per strikeout.
"""
plays = []
for i in range(num_plays):
plays.append(
{
"game_id": game_id,
"play_num": i + 1,
"pitcher_id": pitcher_id,
"pitcher_team_id": team_id,
"batter_id": batter_id,
"batter_team_id": team_id,
"pa": 1,
"ab": 1,
"so": 1,
"outs": 1,
"hit": 0,
"homerun": 0,
"on_base_code": "000",
"inning_half": "bot",
"inning_num": 1,
"batting_order": 1,
"starting_outs": 0,
"away_score": 0,
"home_score": 0,
}
)
return plays
def build_decision_data(
game_id: int,
pitcher_id: int,
team_id: int,
season: int,
) -> dict:
"""Build a minimal pitcher decision payload for refractor testing.
Returns a decisions wrapper dict containing a single no-decision start
entry. All win/loss/hold/save flags default to 0; is_start is True
so the pitcher accrues IP-based refractor value from the associated plays.
Args:
game_id: ID of the game the decision belongs to.
pitcher_id: Card/player ID of the pitcher.
team_id: Team ID for pitcher_team_id.
season: Season number for the decision record.
Returns:
Dict with key "decisions" containing a list with one decision dict.
"""
return {
"decisions": [
{
"game_id": game_id,
"season": season,
"week": 1,
"pitcher_id": pitcher_id,
"pitcher_team_id": team_id,
"win": 0,
"loss": 0,
"hold": 0,
"is_save": 0,
"is_start": True,
"b_save": 0,
}
]
}

View File

@ -131,6 +131,14 @@ async def main():
logger.error(f"Failed to load cog: {c}")
logger.error(f"{e}")
# Load dev-only cogs when not in production
if "prod" not in os.getenv("DATABASE", "dev").lower():
try:
await bot.load_extension("cogs.dev_tools")
logger.info("Loaded dev-only cog: dev_tools")
except Exception as e:
logger.warning(f"Failed to load dev_tools cog: {e}")
# Start health server and bot concurrently
async with bot:
# Create health server task

385
tests/test_dev_tools.py Normal file
View File

@ -0,0 +1,385 @@
"""Tests for the DevToolsCog /dev refractor-test command."""
from unittest.mock import AsyncMock, MagicMock, patch
import discord
import pytest
from discord.ext import commands
class TestCleanupView:
"""Test the cleanup button view for the refractor integration test.
The view presents two buttons after a test run: 'Clean Up Test Data'
deletes the synthetic game/plays/decisions; 'Keep Test Data' dismisses
the buttons. Only the command invoker can press them.
"""
@pytest.fixture
def owner_interaction(self):
"""Interaction from the user who ran the command."""
interaction = AsyncMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = 12345
interaction.response = AsyncMock()
return interaction
@pytest.fixture
def other_interaction(self):
"""Interaction from a different user — should be rejected."""
interaction = AsyncMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = 99999
interaction.response = AsyncMock()
return interaction
async def test_view_has_two_buttons(self):
"""View should have exactly two buttons: cleanup and keep.
Must be async because discord.ui.View.__init__ calls
asyncio.get_running_loop() internally and requires an event loop.
"""
from cogs.dev_tools import CleanupView
view = CleanupView(owner_id=12345, game_id=1, embed=discord.Embed())
buttons = [
child for child in view.children if isinstance(child, discord.ui.Button)
]
assert len(buttons) == 2
async def test_unauthorized_user_ignored(self, other_interaction):
"""Non-owner clicks should be silently ignored."""
from cogs.dev_tools import CleanupView
view = CleanupView(owner_id=12345, game_id=1, embed=discord.Embed())
with patch("cogs.dev_tools.db_delete", new_callable=AsyncMock) as mock_delete:
await view.cleanup_btn.callback(other_interaction)
mock_delete.assert_not_called()
other_interaction.response.edit_message.assert_not_called()
async def test_cleanup_calls_delete_endpoints(self, owner_interaction):
"""Cleanup button deletes decisions, plays, then game in order."""
from cogs.dev_tools import CleanupView
embed = discord.Embed(description="test")
view = CleanupView(owner_id=12345, game_id=42, embed=embed)
with patch("cogs.dev_tools.db_delete", new_callable=AsyncMock) as mock_delete:
await view.cleanup_btn.callback(owner_interaction)
assert mock_delete.call_count == 3
# Verify correct endpoints and order
calls = mock_delete.call_args_list
assert "decisions/game" in str(calls[0])
assert "plays/game" in str(calls[1])
assert "games" in str(calls[2])
async def test_keep_removes_buttons(self, owner_interaction):
"""Keep button removes buttons and updates embed."""
from cogs.dev_tools import CleanupView
embed = discord.Embed(description="test")
view = CleanupView(owner_id=12345, game_id=42, embed=embed)
await view.keep_btn.callback(owner_interaction)
owner_interaction.response.edit_message.assert_called_once()
class TestRefractorTestSetup:
"""Test the setup phase: card lookup, refractor state, plan calculation."""
@pytest.fixture
def mock_interaction(self):
interaction = AsyncMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = 12345
interaction.response = AsyncMock()
interaction.edit_original_response = AsyncMock()
interaction.followup = AsyncMock()
return interaction
@pytest.fixture
def mock_bot(self):
return MagicMock(spec=commands.Bot)
@pytest.fixture
def batting_card_response(self):
return {
"id": 1234,
"player": {"id": 100, "p_name": "Mike Trout"},
"variant": 0,
"image_url": None,
}
@pytest.fixture
def refractor_cards_response(self):
return {
"count": 1,
"items": [
{
"player_id": 100,
"team_id": 31,
"current_tier": 0,
"current_value": 30.0,
"fully_evolved": False,
"track": {
"card_type": "batter",
"t1_threshold": 37,
"t2_threshold": 149,
"t3_threshold": 448,
"t4_threshold": 896,
},
"next_threshold": 37,
"progress_pct": 81.1,
"player_name": "Mike Trout",
"image_url": None,
}
],
}
@pytest.fixture
def opposing_cards_response(self):
"""A valid pitching cards response with the 'cards' key."""
return {
"cards": [
{
"id": 9000,
"player": {"id": 200, "p_name": "Clayton Kershaw"},
"variant": 0,
}
]
}
async def test_batting_card_lookup(
self,
mock_interaction,
mock_bot,
batting_card_response,
refractor_cards_response,
opposing_cards_response,
):
"""Command should try the batting card endpoint first.
Verifies that the first db_get call targets 'battingcards', not
'pitchingcards', when looking up a card ID.
"""
from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot)
with (
patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get,
patch("cogs.dev_tools.db_post", new_callable=AsyncMock),
):
mock_get.side_effect = [
batting_card_response, # GET battingcards/{id}
refractor_cards_response, # GET refractor/cards
opposing_cards_response, # GET pitchingcards (for opposing player)
]
with patch.object(cog, "_execute_refractor_test", new_callable=AsyncMock):
await cog.refractor_test.callback(cog, mock_interaction, card_id=1234)
first_call = mock_get.call_args_list[0]
assert "battingcards" in str(first_call)
async def test_pitching_card_fallback(
self,
mock_interaction,
mock_bot,
refractor_cards_response,
):
"""If batting card returns None, command should fall back to pitching card.
Ensures the two-step lookup: batting first, then pitching if batting
returns None. The second db_get call must target 'pitchingcards'.
"""
from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot)
pitching_card = {
"id": 5678,
"player": {"id": 200, "p_name": "Clayton Kershaw"},
"variant": 0,
"image_url": None,
}
refractor_cards_response["items"][0]["player_id"] = 200
refractor_cards_response["items"][0]["track"]["card_type"] = "sp"
refractor_cards_response["items"][0]["next_threshold"] = 10
opposing_batters = {
"cards": [
{"id": 7000, "player": {"id": 300, "p_name": "Babe Ruth"}, "variant": 0}
]
}
with (
patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get,
patch("cogs.dev_tools.db_post", new_callable=AsyncMock),
):
mock_get.side_effect = [
None, # batting card not found
pitching_card, # pitching card found
refractor_cards_response, # refractor/cards
opposing_batters, # battingcards for opposing player
]
with patch.object(cog, "_execute_refractor_test", new_callable=AsyncMock):
await cog.refractor_test.callback(cog, mock_interaction, card_id=5678)
second_call = mock_get.call_args_list[1]
assert "pitchingcards" in str(second_call)
async def test_card_not_found_reports_error(self, mock_interaction, mock_bot):
"""If neither batting nor pitching card exists, report an error and return.
The command should call edit_original_response with a message containing
'not found' and must NOT call _execute_refractor_test.
"""
from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot)
with patch("cogs.dev_tools.db_get", new_callable=AsyncMock, return_value=None):
with patch.object(
cog, "_execute_refractor_test", new_callable=AsyncMock
) as mock_exec:
await cog.refractor_test.callback(cog, mock_interaction, card_id=9999)
mock_exec.assert_not_called()
call_kwargs = mock_interaction.edit_original_response.call_args[1]
assert "not found" in call_kwargs["content"].lower()
class TestRefractorTestExecute:
"""Test the execution phase: API calls, step-by-step reporting,
stop-on-failure behavior."""
@pytest.fixture
def mock_interaction(self):
interaction = AsyncMock(spec=discord.Interaction)
interaction.user = MagicMock()
interaction.user.id = 12345
interaction.response = AsyncMock()
interaction.edit_original_response = AsyncMock()
return interaction
@pytest.fixture
def mock_bot(self):
return MagicMock(spec=commands.Bot)
@pytest.fixture
def base_embed(self):
embed = discord.Embed(title="Refractor Integration Test")
embed.add_field(name="Setup", value="test setup", inline=False)
embed.add_field(name="", value="⏳ Executing...", inline=False)
return embed
async def test_successful_batter_flow(self, mock_interaction, mock_bot, base_embed):
"""Full happy path: game created, plays inserted, stats updated,
tier-up detected, card rendered."""
from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot)
game_response = {"id": 42}
plays_response = {"count": 3}
decisions_response = {"count": 1}
stats_response = {"updated": 1, "skipped": False}
eval_response = {
"evaluated": 1,
"tier_ups": [
{
"player_id": 100,
"team_id": 31,
"player_name": "Mike Trout",
"old_tier": 0,
"new_tier": 1,
"current_value": 45,
"track_name": "Batter Track",
"variant_created": "abc123",
}
],
}
with (
patch("cogs.dev_tools.db_post", new_callable=AsyncMock) as mock_post,
patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get,
):
mock_post.side_effect = [
game_response, # POST games
plays_response, # POST plays
decisions_response, # POST decisions
stats_response, # POST season-stats/update-game
eval_response, # POST refractor/evaluate-game
]
mock_get.return_value = {"image_url": "https://s3.example.com/card.png"}
await cog._execute_refractor_test(
interaction=mock_interaction,
embed=base_embed,
player_id=100,
team_id=31,
card_type="batter",
card_type_key="batting",
opposing_player_id=200,
num_plays=3,
)
assert mock_interaction.edit_original_response.call_count >= 1
final_call = mock_interaction.edit_original_response.call_args_list[-1]
final_embed = final_call[1]["embed"]
result_text = "\n".join(f.value for f in final_embed.fields if f.value)
assert "" in result_text
assert "game" in result_text.lower()
async def test_stops_on_game_creation_failure(
self, mock_interaction, mock_bot, base_embed
):
"""If game creation fails, stop immediately and show error."""
from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot)
with patch(
"cogs.dev_tools.db_post",
new_callable=AsyncMock,
side_effect=Exception("500 Server Error"),
):
await cog._execute_refractor_test(
interaction=mock_interaction,
embed=base_embed,
player_id=100,
team_id=31,
card_type="batter",
card_type_key="batting",
opposing_player_id=200,
num_plays=3,
)
final_call = mock_interaction.edit_original_response.call_args_list[-1]
final_embed = final_call[1]["embed"]
result_text = "\n".join(f.value for f in final_embed.fields if f.value)
assert "" in result_text
async def test_no_tierup_still_reports_success(
self, mock_interaction, mock_bot, base_embed
):
"""If evaluate-game returns no tier-ups, report it clearly."""
from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot)
with patch("cogs.dev_tools.db_post", new_callable=AsyncMock) as mock_post:
mock_post.side_effect = [
{"id": 42}, # game
{"count": 3}, # plays
{"count": 1}, # decisions
{"updated": 1, "skipped": False}, # stats
{"evaluated": 1, "tier_ups": []}, # no tier-ups
]
await cog._execute_refractor_test(
interaction=mock_interaction,
embed=base_embed,
player_id=100,
team_id=31,
card_type="batter",
card_type_key="batting",
opposing_player_id=200,
num_plays=3,
)
final_call = mock_interaction.edit_original_response.call_args_list[-1]
final_embed = final_call[1]["embed"]
result_text = "\n".join(f.value for f in final_embed.fields if f.value)
assert "no tier-up" in result_text.lower()

View File

@ -0,0 +1,188 @@
import math
import pytest
from helpers.refractor_test_data import (
build_batter_plays,
build_decision_data,
build_game_data,
build_pitcher_plays,
calculate_plays_needed,
)
class TestCalculatePlaysNeeded:
"""Test the pure function that computes how many synthetic plays
are needed to push a card's refractor value over the next tier
threshold. The formulas are:
- batter: each HR play = 9 value (1 PA + 4 TB * 2)
- sp/rp: each K play = 4/3 value (1/3 IP + 1 K)
"""
def test_batter_exact_threshold(self):
"""When the gap is exactly divisible by 9, no extra plays needed."""
result = calculate_plays_needed(gap=27, card_type="batter")
assert result["num_plays"] == 3
assert result["total_value"] == 27
assert result["value_per_play"] == 9
def test_batter_rounds_up(self):
"""When gap isn't divisible by 9, round up to overshoot."""
result = calculate_plays_needed(gap=10, card_type="batter")
assert result["num_plays"] == 2 # ceil(10/9) = 2
assert result["total_value"] == 18
def test_batter_gap_of_one(self):
"""Even a gap of 1 requires one play."""
result = calculate_plays_needed(gap=1, card_type="batter")
assert result["num_plays"] == 1
assert result["total_value"] == 9
def test_sp_exact_threshold(self):
"""SP: each K play = 4/3 value."""
result = calculate_plays_needed(gap=4, card_type="sp")
assert result["num_plays"] == 3 # ceil(4 / (4/3)) = 3
assert result["value_per_play"] == pytest.approx(4 / 3)
def test_rp_same_as_sp(self):
"""RP uses the same formula as SP."""
result = calculate_plays_needed(gap=4, card_type="rp")
assert result["num_plays"] == 3
def test_zero_gap_returns_one_play(self):
"""If already at threshold, still need 1 play to push over."""
result = calculate_plays_needed(gap=0, card_type="batter")
assert result["num_plays"] == 1
class TestBuildGameData:
"""Test synthetic game record construction for refractor testing.
build_game_data creates a self-play game (team vs itself) with
game_type='test' and all score/ranking fields zeroed out. This
gives the minimum valid game payload to POST to the API.
"""
def test_basic_structure(self):
"""Core IDs, type flags, and boolean fields are correct."""
result = build_game_data(team_id=31, season=11)
assert result["away_team_id"] == 31
assert result["home_team_id"] == 31
assert result["season"] == 11
assert result["game_type"] == "test"
assert result["short_game"] is True
assert result["ranked"] is False
assert result["forfeit"] is False
def test_score_reflects_zero(self):
"""Scores start at zero — no actual game was simulated."""
result = build_game_data(team_id=31, season=11)
assert result["away_score"] == 0
assert result["home_score"] == 0
class TestBuildBatterPlays:
"""Test synthetic HR play construction for batter refractor testing.
Each play is a solo HR: PA=1, AB=1, H=1, HR=1, R=1, RBI=1.
Structural fields are filled with safe defaults (inning 1, bot half,
no runners on base, zero scores). play_num is sequential from 1.
"""
def test_correct_count(self):
"""num_plays controls how many play dicts are returned."""
plays = build_batter_plays(
game_id=1, batter_id=100, team_id=31, pitcher_id=200, num_plays=3
)
assert len(plays) == 3
def test_play_fields(self):
"""Each play has correct IDs and HR stat values."""
plays = build_batter_plays(
game_id=1, batter_id=100, team_id=31, pitcher_id=200, num_plays=1
)
play = plays[0]
assert play["game_id"] == 1
assert play["batter_id"] == 100
assert play["batter_team_id"] == 31
assert play["pitcher_id"] == 200
assert play["pitcher_team_id"] == 31
assert play["pa"] == 1
assert play["ab"] == 1
assert play["hit"] == 1
assert play["homerun"] == 1
assert play["run"] == 1
assert play["rbi"] == 1
def test_play_nums_sequential(self):
"""play_num increments from 1 for each play in the batch."""
plays = build_batter_plays(
game_id=1, batter_id=100, team_id=31, pitcher_id=200, num_plays=4
)
assert [p["play_num"] for p in plays] == [1, 2, 3, 4]
def test_required_structural_fields(self):
"""Structural fields are filled with safe defaults for API acceptance."""
plays = build_batter_plays(
game_id=1, batter_id=100, team_id=31, pitcher_id=200, num_plays=1
)
play = plays[0]
assert play["on_base_code"] == "000"
assert play["inning_half"] == "bot"
assert play["inning_num"] == 1
assert play["batting_order"] == 1
assert play["starting_outs"] == 0
assert play["away_score"] == 0
assert play["home_score"] == 0
class TestBuildPitcherPlays:
"""Test synthetic strikeout play construction for pitcher refractor testing.
Each play is a K: PA=1, AB=1, SO=1, outs=1, hit=0, homerun=0.
Structural fields mirror the batter play defaults.
"""
def test_correct_count(self):
"""num_plays controls how many play dicts are returned."""
plays = build_pitcher_plays(
game_id=1, pitcher_id=200, team_id=31, batter_id=100, num_plays=5
)
assert len(plays) == 5
def test_play_fields(self):
"""Each play has correct IDs and K stat values."""
plays = build_pitcher_plays(
game_id=1, pitcher_id=200, team_id=31, batter_id=100, num_plays=1
)
play = plays[0]
assert play["game_id"] == 1
assert play["pitcher_id"] == 200
assert play["pitcher_team_id"] == 31
assert play["batter_id"] == 100
assert play["batter_team_id"] == 31
assert play["pa"] == 1
assert play["ab"] == 1
assert play["so"] == 1
assert play["outs"] == 1
assert play["hit"] == 0
assert play["homerun"] == 0
class TestBuildDecisionData:
"""Test synthetic pitcher decision construction for refractor testing.
Returns a decisions payload with a single no-decision start entry.
All win/loss/hold/save flags default to 0; is_start is True.
"""
def test_basic_structure(self):
"""Decisions payload has correct IDs, season, and default flags."""
result = build_decision_data(game_id=1, pitcher_id=200, team_id=31, season=11)
assert result["decisions"][0]["game_id"] == 1
assert result["decisions"][0]["pitcher_id"] == 200
assert result["decisions"][0]["pitcher_team_id"] == 31
assert result["decisions"][0]["season"] == 11
assert result["decisions"][0]["is_start"] is True
assert result["decisions"][0]["win"] == 0
assert result["decisions"][0]["loss"] == 0