From 9922f3bb3ecc8df0bba05df55953a221a04e8dc8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 06:55:47 -0500 Subject: [PATCH 1/9] chore: add .worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 11534e2..0bf001c 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,5 @@ storage/paper-dynasty-service-creds.json **.db **/htmlcov .vscode/** -.claude/** \ No newline at end of file +.claude/** +.worktrees/ \ No newline at end of file From 0aeed0c76ffb3169d382ac96607bd9c0d4db2f17 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:05:43 -0500 Subject: [PATCH 2/9] feat: add refractor test stat calculation helpers Co-Authored-By: Claude Opus 4.6 (1M context) --- helpers/refractor_test_data.py | 43 +++++++++++++++++++++++++++ tests/test_refractor_test_data.py | 49 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 helpers/refractor_test_data.py create mode 100644 tests/test_refractor_test_data.py diff --git a/helpers/refractor_test_data.py b/helpers/refractor_test_data.py new file mode 100644 index 0000000..eef30ec --- /dev/null +++ b/helpers/refractor_test_data.py @@ -0,0 +1,43 @@ +"""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, + } diff --git a/tests/test_refractor_test_data.py b/tests/test_refractor_test_data.py new file mode 100644 index 0000000..e146e6f --- /dev/null +++ b/tests/test_refractor_test_data.py @@ -0,0 +1,49 @@ +import math + +import pytest + +from helpers.refractor_test_data import 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 From 777e6de3def15044bd37856369c174fba192a7ae Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:06:37 -0500 Subject: [PATCH 3/9] feat: add CleanupView for refractor test data Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/dev_tools.py | 77 +++++++++++++++++++++++++++++++++++++++ tests/test_dev_tools.py | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 cogs/dev_tools.py create mode 100644 tests/test_dev_tools.py diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py new file mode 100644 index 0000000..18a3512 --- /dev/null +++ b/cogs/dev_tools.py @@ -0,0 +1,77 @@ +"""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 + +import discord +from discord import app_commands +from discord.ext import commands + +from api_calls import db_delete + +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): + self.clear_items() diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py new file mode 100644 index 0000000..ec7bc4f --- /dev/null +++ b/tests/test_dev_tools.py @@ -0,0 +1,81 @@ +"""Tests for the DevToolsCog /dev refractor-test command.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +import pytest + + +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() From 2067a02a230dc29c56416e48ee0123bc4085380d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:12:15 -0500 Subject: [PATCH 4/9] feat: add play/game/decision data builders for refractor test Co-Authored-By: Claude Opus 4.6 (1M context) --- helpers/refractor_test_data.py | 170 ++++++++++++++++++++++++++++++ tests/test_refractor_test_data.py | 141 ++++++++++++++++++++++++- 2 files changed, 310 insertions(+), 1 deletion(-) diff --git a/helpers/refractor_test_data.py b/helpers/refractor_test_data.py index eef30ec..679e3a2 100644 --- a/helpers/refractor_test_data.py +++ b/helpers/refractor_test_data.py @@ -41,3 +41,173 @@ def calculate_plays_needed(gap: int, card_type: str) -> dict: "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, + } + ] + } diff --git a/tests/test_refractor_test_data.py b/tests/test_refractor_test_data.py index e146e6f..1fd7547 100644 --- a/tests/test_refractor_test_data.py +++ b/tests/test_refractor_test_data.py @@ -2,7 +2,13 @@ import math import pytest -from helpers.refractor_test_data import calculate_plays_needed +from helpers.refractor_test_data import ( + build_batter_plays, + build_decision_data, + build_game_data, + build_pitcher_plays, + calculate_plays_needed, +) class TestCalculatePlaysNeeded: @@ -47,3 +53,136 @@ class TestCalculatePlaysNeeded: """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 From 129971e96bfcc48b3bada4d9074d5e0ea3308887 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:20:38 -0500 Subject: [PATCH 5/9] feat: add DevToolsCog with refractor-test setup phase Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/dev_tools.py | 178 +++++++++++++++++++++++++++++++++++++++- tests/test_dev_tools.py | 160 ++++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 1 deletion(-) diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py index 18a3512..87b17e4 100644 --- a/cogs/dev_tools.py +++ b/cogs/dev_tools.py @@ -5,12 +5,24 @@ for integration testing that create and clean up synthetic test data. """ import logging +import os +from datetime import date import discord from discord import app_commands from discord.ext import commands -from api_calls import db_delete +from api_calls import db_delete, db_get, db_post +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 = 11 logger = logging.getLogger(__name__) @@ -75,3 +87,167 @@ class CleanupView(discord.ui.View): async def on_timeout(self): 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: + from helpers.main import get_team_by_owner + + 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, + player_name=player_name, + 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, + player_name: str, + team_id: int, + card_type: str, + card_type_key: str, + opposing_player_id: int, + num_plays: int, + ): + """Execute phase placeholder — implemented in Task 5.""" + raise NotImplementedError("Execute phase not yet implemented") + + +async def setup(bot: commands.Bot): + await bot.add_cog(DevToolsCog(bot)) diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py index ec7bc4f..334bc3b 100644 --- a/tests/test_dev_tools.py +++ b/tests/test_dev_tools.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import discord import pytest +from discord.ext import commands class TestCleanupView: @@ -79,3 +80,162 @@ class TestCleanupView: 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() From 9257852c3e3bfdc798920db182c15667ada32f98 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:53:10 -0500 Subject: [PATCH 6/9] feat: add refractor-test execute phase with step-by-step reporting Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/dev_tools.py | 153 +++++++++++++++++++++++++++++++++++++++- tests/test_dev_tools.py | 147 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 2 deletions(-) diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py index 87b17e4..9101ae8 100644 --- a/cogs/dev_tools.py +++ b/cogs/dev_tools.py @@ -245,8 +245,157 @@ class DevToolsCog(commands.Cog): opposing_player_id: int, num_plays: int, ): - """Execute phase placeholder — implemented in Task 5.""" - raise NotImplementedError("Execute phase not yet implemented") + """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( + final: bool = False, 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) + kwargs = {"embed": embed} + if view is not None: + kwargs["view"] = view + await interaction.edit_original_response(**kwargs) + + 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( + final=True, 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( + final=True, 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( + final=True, 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( + final=True, 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( + final=True, view=CleanupView(interaction.user.id, game_id, embed) + ) async def setup(bot: commands.Bot): diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py index 334bc3b..c36ba34 100644 --- a/tests/test_dev_tools.py +++ b/tests/test_dev_tools.py @@ -239,3 +239,150 @@ class TestRefractorTestSetup: 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, + player_name="Mike Trout", + 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, + player_name="Mike Trout", + 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, + player_name="Mike Trout", + 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() From c683e36dafb7a343068cfb6f20e1794e8ce31a60 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 07:54:33 -0500 Subject: [PATCH 7/9] feat: conditionally load dev_tools cog when not in prod Co-Authored-By: Claude Opus 4.6 (1M context) --- paperdynasty.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/paperdynasty.py b/paperdynasty.py index 219ce90..65f3845 100644 --- a/paperdynasty.py +++ b/paperdynasty.py @@ -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 From 5b43f8dad3d9ec2e82c490c5fbdddd27c0c7d9e9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 08:00:11 -0500 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20use=20PD=5FSEASON,=20top-level=20imports,=20fix=20kwargs=20t?= =?UTF-8?q?yping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded CURRENT_SEASON = 11 with PD_SEASON from helpers.constants - Move get_team_by_owner import to top-level (no circular dependency) - Replace kwargs dict unpacking with explicit keyword args (fixes Pyright) - Remove unused os import - Add comment documenting on_timeout limitation Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/dev_tools.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py index 9101ae8..b321bce 100644 --- a/cogs/dev_tools.py +++ b/cogs/dev_tools.py @@ -5,7 +5,6 @@ for integration testing that create and clean up synthetic test data. """ import logging -import os from datetime import date import discord @@ -13,6 +12,8 @@ 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, @@ -22,7 +23,7 @@ from helpers.refractor_test_data import ( calculate_plays_needed, ) -CURRENT_SEASON = 11 +CURRENT_SEASON = PD_SEASON logger = logging.getLogger(__name__) @@ -86,6 +87,8 @@ class CleanupView(discord.ui.View): 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() @@ -125,8 +128,6 @@ class DevToolsCog(commands.Cog): team_id = card.get("team_id") or card["player"].get("team_id") if team_id is None: - from helpers.main import get_team_by_owner - team = await get_team_by_owner(interaction.user.id) if team is None: await interaction.edit_original_response( @@ -266,10 +267,10 @@ class DevToolsCog(commands.Cog): while len(embed.fields) > 1: embed.remove_field(len(embed.fields) - 1) embed.add_field(name="Results", value=results_text, inline=False) - kwargs = {"embed": embed} if view is not None: - kwargs["view"] = view - await interaction.edit_original_response(**kwargs) + await interaction.edit_original_response(embed=embed, view=view) + else: + await interaction.edit_original_response(embed=embed) try: # Step 1: Create game From f3a83f91fd0ec11f4b3ffa905513c8af220b5a1a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 9 Apr 2026 08:34:02 -0500 Subject: [PATCH 9/9] fix: remove dead parameters from PR review feedback Remove unused `player_name` param from `_execute_refractor_test` and unused `final` param from `update_embed` closure. Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/dev_tools.py | 26 ++++++-------------------- tests/test_dev_tools.py | 3 --- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py index b321bce..f36b850 100644 --- a/cogs/dev_tools.py +++ b/cogs/dev_tools.py @@ -226,7 +226,6 @@ class DevToolsCog(commands.Cog): interaction=interaction, embed=embed, player_id=player_id, - player_name=player_name, team_id=team_id, card_type=card_type, card_type_key=card_type_key, @@ -239,7 +238,6 @@ class DevToolsCog(commands.Cog): interaction: discord.Interaction, embed: discord.Embed, player_id: int, - player_name: str, team_id: int, card_type: str, card_type_key: str, @@ -259,9 +257,7 @@ class DevToolsCog(commands.Cog): embed.remove_field(len(embed.fields) - 1) # Helper to update the embed with current results - async def update_embed( - final: bool = False, view: discord.ui.View | None = None - ): + 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: @@ -299,9 +295,7 @@ class DevToolsCog(commands.Cog): await update_embed() except Exception as e: results.append(f"❌ Play insertion failed: {e}") - await update_embed( - final=True, view=CleanupView(interaction.user.id, game_id, embed) - ) + await update_embed(view=CleanupView(interaction.user.id, game_id, embed)) return try: @@ -315,9 +309,7 @@ class DevToolsCog(commands.Cog): await update_embed() except Exception as e: results.append(f"❌ Decision insertion failed: {e}") - await update_embed( - final=True, view=CleanupView(interaction.user.id, game_id, embed) - ) + await update_embed(view=CleanupView(interaction.user.id, game_id, embed)) return try: @@ -332,9 +324,7 @@ class DevToolsCog(commands.Cog): except Exception as e: results.append(f"❌ Season stats update failed: {e}") results.append("⏭️ Skipped: evaluate-game (depends on season stats)") - await update_embed( - final=True, view=CleanupView(interaction.user.id, game_id, embed) - ) + await update_embed(view=CleanupView(interaction.user.id, game_id, embed)) return try: @@ -356,9 +346,7 @@ class DevToolsCog(commands.Cog): await update_embed() except Exception as e: results.append(f"❌ Evaluate-game failed: {e}") - await update_embed( - final=True, view=CleanupView(interaction.user.id, game_id, embed) - ) + await update_embed(view=CleanupView(interaction.user.id, game_id, embed)) return # Step 6: Trigger card render (if tier-up) @@ -394,9 +382,7 @@ class DevToolsCog(commands.Cog): results.append(f"⚠️ Card render failed (non-fatal): {e}") # Final update with cleanup buttons - await update_embed( - final=True, view=CleanupView(interaction.user.id, game_id, embed) - ) + await update_embed(view=CleanupView(interaction.user.id, game_id, embed)) async def setup(bot: commands.Bot): diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py index c36ba34..ca04ec3 100644 --- a/tests/test_dev_tools.py +++ b/tests/test_dev_tools.py @@ -309,7 +309,6 @@ class TestRefractorTestExecute: interaction=mock_interaction, embed=base_embed, player_id=100, - player_name="Mike Trout", team_id=31, card_type="batter", card_type_key="batting", @@ -341,7 +340,6 @@ class TestRefractorTestExecute: interaction=mock_interaction, embed=base_embed, player_id=100, - player_name="Mike Trout", team_id=31, card_type="batter", card_type_key="batting", @@ -374,7 +372,6 @@ class TestRefractorTestExecute: interaction=mock_interaction, embed=base_embed, player_id=100, - player_name="Mike Trout", team_id=31, card_type="batter", card_type_key="batting",