paper-dynasty-discord/tests/test_dev_tools.py
Cal Corum f3a83f91fd
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
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) <noreply@anthropic.com>
2026-04-09 08:34:02 -05:00

386 lines
14 KiB
Python

"""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()