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 diff --git a/cogs/dev_tools.py b/cogs/dev_tools.py new file mode 100644 index 0000000..f36b850 --- /dev/null +++ b/cogs/dev_tools.py @@ -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)) diff --git a/helpers/refractor_test_data.py b/helpers/refractor_test_data.py new file mode 100644 index 0000000..679e3a2 --- /dev/null +++ b/helpers/refractor_test_data.py @@ -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, + } + ] + } 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 diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py new file mode 100644 index 0000000..ca04ec3 --- /dev/null +++ b/tests/test_dev_tools.py @@ -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() diff --git a/tests/test_refractor_test_data.py b/tests/test_refractor_test_data.py new file mode 100644 index 0000000..1fd7547 --- /dev/null +++ b/tests/test_refractor_test_data.py @@ -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