All checks were successful
Ruff Lint / lint (pull_request) Successful in 27s
The previous two-step battingcards→pitchingcards fallback caused card
ID collisions — e.g. card 494 resolving to Cameron Maybin (batting)
instead of the intended pitcher Grayson Rodriguez. The unified cards
endpoint is keyed on globally-unique card instance IDs and carries
player, team, and variant in a single response.
- Single db_get("cards", object_id=card_id) lookup
- Card type derived from player.pos_1 (SP→sp, RP/CP→rp, else→batter)
- team_id sourced from card["team"]["id"] (no get_team_by_owner fallback)
- TestRefractorTestSetup rewritten for the single-endpoint contract
Spec: docs/superpowers/specs/2026-04-11-refractor-test-unified-cards-lookup-design.md
507 lines
19 KiB
Python
507 lines
19 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 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()
|