"""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 unified_card_response(self): """Factory for a response from GET /v2/cards/{id}. The unified cards endpoint returns a card-instance record with top-level player, team, pack, value, and variant fields. This replaces the separate battingcards/pitchingcards template endpoints that previously caused ID collisions (see spec 2026-04-11-refractor-test-unified-cards-lookup-design.md). The factory lets each test customize pos_1 and the IDs without duplicating the full response shape. """ def _make(pos_1="CF", player_id=100, team_id=31, card_id=1234): return { "id": card_id, "player": { "player_id": player_id, "p_name": "Mike Trout", "pos_1": pos_1, }, "team": {"id": team_id}, "variant": 0, "pack": None, "value": None, } return _make @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, } ], } async def test_unified_card_lookup( self, mock_interaction, mock_bot, unified_card_response, refractor_cards_response, ): """The setup phase should make a single db_get call targeting the unified 'cards' endpoint. Regression guard for the previous two-step battingcards/pitchingcards fallback that caused ID collisions (e.g. card 494 resolving to Cameron Maybin instead of the intended pitcher Grayson Rodriguez). """ from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) card = unified_card_response(pos_1="CF", player_id=100, card_id=1234) 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 = [card, refractor_cards_response] with patch.object(cog, "_execute_refractor_test", new_callable=AsyncMock): await cog.refractor_test.callback(cog, mock_interaction, 1234) # First call: the unified cards endpoint first_call = mock_get.call_args_list[0] assert first_call.args[0] == "cards" assert first_call.kwargs.get("object_id") == 1234 # No secondary template-table fallback endpoints_called = [c.args[0] for c in mock_get.call_args_list] assert "battingcards" not in endpoints_called assert "pitchingcards" not in endpoints_called async def test_card_not_found_reports_error(self, mock_interaction, mock_bot): """If cards/{id} returns None, report 'not found' and never call _execute_refractor_test. The single unified endpoint means only one db_get is made before the error path. """ from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) with patch( "cogs.dev_tools.db_get", new_callable=AsyncMock, return_value=None ) as mock_get: with patch.object( cog, "_execute_refractor_test", new_callable=AsyncMock ) as mock_exec: await cog.refractor_test.callback(cog, mock_interaction, 9999) mock_exec.assert_not_called() # Exactly one db_get call — the unified lookup, no template fallback assert mock_get.call_count == 1 call_kwargs = mock_interaction.edit_original_response.call_args[1] assert "not found" in call_kwargs["content"].lower() async def test_pos_sp_derives_sp_type( self, mock_interaction, mock_bot, unified_card_response, refractor_cards_response, ): """pos_1='SP' should derive card_type='sp', card_type_key='pitching' and pass those into _execute_refractor_test. """ from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) card = unified_card_response(pos_1="SP", player_id=200) # Make sure the refractor/cards lookup finds no matching entry, # so the command falls through to the pos_1-derived defaults. refractor_cards_response["items"] = [] with ( patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, patch("cogs.dev_tools.db_post", new_callable=AsyncMock), patch.object( cog, "_execute_refractor_test", new_callable=AsyncMock ) as mock_exec, ): mock_get.side_effect = [card, refractor_cards_response] await cog.refractor_test.callback(cog, mock_interaction, 1234) kwargs = mock_exec.call_args.kwargs assert kwargs["card_type"] == "sp" assert kwargs["card_type_key"] == "pitching" async def test_pos_rp_derives_rp_type( self, mock_interaction, mock_bot, unified_card_response, refractor_cards_response, ): """pos_1='RP' should derive card_type='rp', card_type_key='pitching'.""" from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) card = unified_card_response(pos_1="RP", player_id=201) refractor_cards_response["items"] = [] with ( patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, patch("cogs.dev_tools.db_post", new_callable=AsyncMock), patch.object( cog, "_execute_refractor_test", new_callable=AsyncMock ) as mock_exec, ): mock_get.side_effect = [card, refractor_cards_response] await cog.refractor_test.callback(cog, mock_interaction, 1234) kwargs = mock_exec.call_args.kwargs assert kwargs["card_type"] == "rp" assert kwargs["card_type_key"] == "pitching" async def test_pos_cp_derives_rp_type( self, mock_interaction, mock_bot, unified_card_response, refractor_cards_response, ): """pos_1='CP' (closer) should also map to card_type='rp'.""" from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) card = unified_card_response(pos_1="CP", player_id=202) refractor_cards_response["items"] = [] with ( patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, patch("cogs.dev_tools.db_post", new_callable=AsyncMock), patch.object( cog, "_execute_refractor_test", new_callable=AsyncMock ) as mock_exec, ): mock_get.side_effect = [card, refractor_cards_response] await cog.refractor_test.callback(cog, mock_interaction, 1234) kwargs = mock_exec.call_args.kwargs assert kwargs["card_type"] == "rp" assert kwargs["card_type_key"] == "pitching" async def test_pos_batter_derives_batter_type( self, mock_interaction, mock_bot, unified_card_response, refractor_cards_response, ): """pos_1='CF' (or any non-pitcher position) should derive card_type='batter', card_type_key='batting'. """ from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) card = unified_card_response(pos_1="CF", player_id=203) refractor_cards_response["items"] = [] with ( patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, patch("cogs.dev_tools.db_post", new_callable=AsyncMock), patch.object( cog, "_execute_refractor_test", new_callable=AsyncMock ) as mock_exec, ): mock_get.side_effect = [card, refractor_cards_response] await cog.refractor_test.callback(cog, mock_interaction, 1234) kwargs = mock_exec.call_args.kwargs assert kwargs["card_type"] == "batter" assert kwargs["card_type_key"] == "batting" async def test_card_without_team_reports_error( self, mock_interaction, mock_bot, unified_card_response, ): """If the unified card response has team=None, the command should report an error and not proceed to execute the refractor chain. The card instance's owning team is now the authoritative team context for the test (see spec — option A: card's team is authoritative, no get_team_by_owner fallback). """ from cogs.dev_tools import DevToolsCog cog = DevToolsCog(mock_bot) card = unified_card_response(pos_1="CF", player_id=100) card["team"] = None with patch("cogs.dev_tools.db_get", new_callable=AsyncMock, return_value=card): with patch.object( cog, "_execute_refractor_test", new_callable=AsyncMock ) as mock_exec: await cog.refractor_test.callback(cog, mock_interaction, 1234) mock_exec.assert_not_called() call_kwargs = mock_interaction.edit_original_response.call_args[1] assert "no owning team" 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()