Compare commits

...

13 Commits

Author SHA1 Message Date
cal
3481291259 Merge pull request 'perf: parallelize per-pack card fetches after roll_for_cards (#99)' (#167) from issue/99-perf-parallelize-per-pack-card-fetches-after-roll into main
Reviewed-on: #167
Reviewed-by: Claude Reviewer <cal.corum+claude-reviewer@gmail.com>
2026-04-12 14:54:12 +00:00
Cal Corum
6b475ba439 perf: parallelize per-pack card fetches after roll_for_cards (#99)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
Closes #99

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:53:37 +00:00
cal
61d61b9348 Merge pull request 'perf: replace blocking requests.get with aiohttp in get_player_headshot (#100)' (#166) from issue/100-perf-replace-blocking-requests-get-with-aiohttp-in into main
Reviewed-on: #166
Reviewed-by: Claude Reviewer <cal.corum+claude-reviewer@gmail.com>
2026-04-12 14:53:13 +00:00
Cal Corum
25551130e9 perf: replace blocking requests.get with aiohttp in get_player_headshot (#100)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 16s
Closes #100

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:52:12 +00:00
cal
d83ee3e5a0 Merge pull request 'fix(gameplay): replace bare except with NoResultFound in cache-miss paths' (#163) from autonomous/fix-gameplay-queries-bare-except into main
Reviewed-on: #163
Reviewed-by: Claude <cal.corum+openclaw@gmail.com>
2026-04-12 14:42:19 +00:00
Cal Corum
687cdad97f fix(gameplay): replace bare except with NoResultFound in cache-miss paths
All checks were successful
Ruff Lint / lint (pull_request) Successful in 18s
In gameplay_queries.py, 10 try/except blocks used `except Exception:` to
detect SQLModel row-not-found cache misses (.one() returning no result).
This silently swallowed connection failures, attribute errors, and
programming bugs on the gameplay hot path.

Narrowed each handler to `except NoResultFound:` (sqlalchemy.exc).
Real errors now propagate instead of being misinterpreted as cache misses.

Refs: autonomous-pipeline finding analyst-2026-04-10-003
Category: stability / error_handling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:41:24 +00:00
cal
71f47eb412 Merge pull request 'fix: use card_type field instead of track_name string in variant renders (#149)' (#153) from issue/149-fix-variant-render-uses-fragile-track-name-string into main
Reviewed-on: #153
2026-04-12 14:39:49 +00:00
Cal Corum
9eb9669151 fix: use card_type field instead of track_name string in variant renders (#149)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 29s
Closes #149

Replaces fragile track_name string matching ('Pitcher' check) with the
structured card_type field ('sp', 'rp', 'batter') already present in
refractor API data. Prevents silent wrong-type renders when track_name
varies ('Starting Pitcher', 'SP', etc.). Updates test fixtures and adds
assertions that verify the correct card type URL segment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:38:44 +00:00
cal
9127c9a00b Merge pull request 'test(dev_tools): fix dead render mock in test_successful_batter_flow (#169)' (#170) from issue/169-test-dev-tools-refractor-test-render-step-mock-is into main
Reviewed-on: #170
Reviewed-by: Claude Reviewer <cal.corum+claude-reviewer@gmail.com>
2026-04-12 14:36:24 +00:00
Cal Corum
00d746bf5a test(dev_tools): fix dead render mock in test_successful_batter_flow (#169)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 16s
Replaces dead db_get mock (never consumed by the render step) with a
proper aiohttp.ClientSession mock. The render call uses aiohttp directly,
so the old mock hit the non-fatal except branch instead of the 200-OK path.
Adds explicit assertion for ' Card rendered + S3 upload triggered'.

Closes #169

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:33:12 -05:00
cal
46c0d1ae1d Merge pull request 'fix(dev_tools): use unified cards/{id} endpoint for /dev refractor-test' (#168) from fix/refractor-test-unified-cards-lookup into main
All checks were successful
Build Docker Image / build (push) Successful in 3m22s
2026-04-11 17:22:21 +00:00
Cal Corum
c8424e6cb1 docs(dev_tools): update refractor-test card_id describe string
All checks were successful
Ruff Lint / lint (pull_request) Successful in 17s
Review follow-up: the @app_commands.describe string still referenced
"batting or pitching card ID" after the switch to the unified cards
endpoint. Update to clarify that card_id is now a card-instance ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:19:46 -05:00
Cal Corum
1c47928356 fix(dev_tools): use unified cards/{id} endpoint for /dev refractor-test
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
2026-04-11 12:07:35 -05:00
6 changed files with 1505 additions and 779 deletions

View File

@ -11,9 +11,10 @@ import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from api_calls import db_delete, db_get, db_post import aiohttp
from api_calls import AUTH_TOKEN, db_delete, db_get, db_post, get_req_url
from helpers.constants import PD_SEASON from helpers.constants import PD_SEASON
from helpers.main import get_team_by_owner
from helpers.refractor_constants import TIER_NAMES from helpers.refractor_constants import TIER_NAMES
from helpers.refractor_test_data import ( from helpers.refractor_test_data import (
build_batter_plays, build_batter_plays,
@ -25,7 +26,9 @@ from helpers.refractor_test_data import (
CURRENT_SEASON = PD_SEASON CURRENT_SEASON = PD_SEASON
logger = logging.getLogger(__name__) SENTINEL_PITCHER_ID = 3
logger = logging.getLogger("discord_app")
class CleanupView(discord.ui.View): class CleanupView(discord.ui.View):
@ -105,36 +108,45 @@ class DevToolsCog(commands.Cog):
@group_dev.command( @group_dev.command(
name="refractor-test", description="Run refractor integration test on a card" name="refractor-test", description="Run refractor integration test on a card"
) )
@app_commands.describe(card_id="The batting or pitching card ID to test") @app_commands.describe(
card_id="Card-instance ID (from the unified cards table; discoverable via /refractor status)"
)
async def refractor_test(self, interaction: discord.Interaction, card_id: int): async def refractor_test(self, interaction: discord.Interaction, card_id: int):
await interaction.response.defer() await interaction.response.defer()
# --- Phase 1: Setup --- # --- Phase 1: Setup ---
# Look up card (try batting first, then pitching) # Look up card via the unified cards endpoint. cards.id is
card = await db_get("battingcards", object_id=card_id) # globally unique across all card instances, so there's no
card_type_key = "batting" # batting/pitching template-ID collision (spec
if card is None: # 2026-04-11-refractor-test-unified-cards-lookup-design.md).
card = await db_get("pitchingcards", object_id=card_id) card = await db_get("cards", object_id=card_id)
card_type_key = "pitching"
if card is None: if card is None:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f"❌ Card #{card_id} not found (checked batting and pitching)." content=f"❌ Card #{card_id} not found."
) )
return return
player_id = card["player"]["id"] player_id = card["player"]["player_id"]
player_name = card["player"]["p_name"] player_name = card["player"]["p_name"]
team_id = card.get("team_id") or card["player"].get("team_id")
# The card instance's owning team is the authoritative context
# for this test — its refractor state will be read and synthetic
# plays will be credited against it.
team_id = (card.get("team") or {}).get("id")
if team_id is None: if team_id is None:
team = await get_team_by_owner(interaction.user.id) await interaction.edit_original_response(
if team is None: content=f"❌ Card #{card_id} has no owning team."
await interaction.edit_original_response( )
content="❌ Could not determine team ID. You must own a team." return
)
return # Derive card type from the player's primary position.
team_id = team["id"] pos_1 = (card["player"].get("pos_1") or "").upper()
if pos_1 == "SP":
card_type_key, card_type_default = "pitching", "sp"
elif pos_1 in ("RP", "CP"):
card_type_key, card_type_default = "pitching", "rp"
else:
card_type_key, card_type_default = "batting", "batter"
# Fetch refractor state # Fetch refractor state
refractor_data = await db_get( refractor_data = await db_get(
@ -142,6 +154,17 @@ class DevToolsCog(commands.Cog):
params=[("team_id", team_id), ("limit", 100)], params=[("team_id", team_id), ("limit", 100)],
) )
logger.info(
"dev_tools state-lookup: team_id=%s player_id=%s resp_keys=%s item_count=%s item_pids=%s",
team_id,
player_id,
list(refractor_data.keys()) if refractor_data else None,
len(refractor_data.get("items", [])) if refractor_data else 0,
[i.get("player_id") for i in refractor_data.get("items", [])]
if refractor_data
else [],
)
# Find this player's entry # Find this player's entry
card_state = None card_state = None
if refractor_data and refractor_data.get("items"): if refractor_data and refractor_data.get("items"):
@ -150,6 +173,12 @@ class DevToolsCog(commands.Cog):
card_state = item card_state = item
break break
logger.info(
"dev_tools state-lookup matched: player_id=%s card_state=%s",
player_id,
card_state,
)
# Determine current state and thresholds # Determine current state and thresholds
if card_state: if card_state:
current_tier = card_state["current_tier"] current_tier = card_state["current_tier"]
@ -159,7 +188,7 @@ class DevToolsCog(commands.Cog):
else: else:
current_tier = 0 current_tier = 0
current_value = 0 current_value = 0
card_type = "batter" if card_type_key == "batting" else "sp" card_type = card_type_default
next_threshold = ( next_threshold = (
37 if card_type == "batter" else (10 if card_type == "sp" else 3) 37 if card_type == "batter" else (10 if card_type == "sp" else 3)
) )
@ -174,25 +203,7 @@ class DevToolsCog(commands.Cog):
gap = max(0, next_threshold - current_value) gap = max(0, next_threshold - current_value)
plan = calculate_plays_needed(gap, card_type) plan = calculate_plays_needed(gap, card_type)
# Find an opposing player opposing_player_id = SENTINEL_PITCHER_ID
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 # Build and send initial embed
tier_name = TIER_NAMES.get(current_tier, f"T{current_tier}") tier_name = TIER_NAMES.get(current_tier, f"T{current_tier}")
@ -357,27 +368,19 @@ class DevToolsCog(commands.Cog):
continue continue
try: try:
today = date.today().isoformat() today = date.today().isoformat()
render_resp = await db_get( render_url = get_req_url(
f"players/{tu['player_id']}/{card_type_key}card/{today}/{variant}", f"players/{tu['player_id']}/{card_type_key}card/{today}/{variant}",
none_okay=True, api_ver=2,
) )
if render_resp: async with aiohttp.ClientSession(headers=AUTH_TOKEN) as sess:
results.append("✅ Card rendered + S3 upload triggered") async with sess.get(render_url) as r:
img_url = ( if r.status == 200:
render_resp results.append("✅ Card rendered + S3 upload triggered")
if isinstance(render_resp, str) else:
else render_resp.get("image_url") body = await r.text()
) results.append(
if ( f"⚠️ Card render non-200 ({r.status}): {body[:80]}"
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: except Exception as e:
results.append(f"⚠️ Card render failed (non-fatal): {e}") results.append(f"⚠️ Card render failed (non-fatal): {e}")

View File

@ -4293,8 +4293,8 @@ async def _trigger_variant_renders(tier_ups: list) -> dict:
if variant is None: if variant is None:
continue continue
player_id = tier_up["player_id"] player_id = tier_up["player_id"]
track = tier_up.get("track_name", "Batter") ct = tier_up.get("card_type", "batter")
card_type = "pitching" if track.lower() == "pitcher" else "batting" card_type = "pitching" if ct in ("sp", "rp") else "batting"
try: try:
result = await db_get( result = await db_get(
f"players/{player_id}/{card_type}card/{today}/{variant}", f"players/{player_id}/{card_type}card/{today}/{variant}",

View File

@ -59,8 +59,12 @@ async def get_player_headshot(player):
) )
try: try:
resp = requests.get(req_url, timeout=2).text async with aiohttp.ClientSession() as session:
soup = BeautifulSoup(resp, "html.parser") async with session.get(
req_url, timeout=aiohttp.ClientTimeout(total=2)
) as resp:
text = await resp.text()
soup = BeautifulSoup(text, "html.parser")
for item in soup.find_all("img"): for item in soup.find_all("img"):
if "headshot" in item["src"]: if "headshot" in item["src"]:
await db_patch( await db_patch(
@ -1761,12 +1765,14 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}") logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}")
raise ValueError("I was not able to unpack these cards") raise ValueError("I was not able to unpack these cards")
all_cards = [] async def _fetch_pack_cards(p_id):
for p_id in pack_ids: result = await db_get("cards", params=[("pack_id", p_id)])
new_cards = await db_get("cards", params=[("pack_id", p_id)]) for card in result["cards"]:
for card in new_cards["cards"]:
card.setdefault("pack_id", p_id) card.setdefault("pack_id", p_id)
all_cards.extend(new_cards["cards"]) return result["cards"]
results = await asyncio.gather(*[_fetch_pack_cards(p_id) for p_id in pack_ids])
all_cards = [card for pack_cards in results for card in pack_cards]
if not all_cards: if not all_cards:
logger.error(f"open_packs - unable to get cards for packs: {pack_ids}") logger.error(f"open_packs - unable to get cards for packs: {pack_ids}")

File diff suppressed because it is too large Load Diff

View File

@ -100,13 +100,34 @@ class TestRefractorTestSetup:
return MagicMock(spec=commands.Bot) return MagicMock(spec=commands.Bot)
@pytest.fixture @pytest.fixture
def batting_card_response(self): def unified_card_response(self):
return { """Factory for a response from GET /v2/cards/{id}.
"id": 1234,
"player": {"id": 100, "p_name": "Mike Trout"}, The unified cards endpoint returns a card-instance record with
"variant": 0, top-level player, team, pack, value, and variant fields. This
"image_url": None, 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 @pytest.fixture
def refractor_cards_response(self): def refractor_cards_response(self):
@ -134,112 +155,212 @@ class TestRefractorTestSetup:
], ],
} }
@pytest.fixture async def test_unified_card_lookup(
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, self,
mock_interaction, mock_interaction,
mock_bot, mock_bot,
batting_card_response, unified_card_response,
refractor_cards_response, refractor_cards_response,
opposing_cards_response,
): ):
"""Command should try the batting card endpoint first. """The setup phase should make a single db_get call targeting
the unified 'cards' endpoint.
Verifies that the first db_get call targets 'battingcards', not Regression guard for the previous two-step battingcards/pitchingcards
'pitchingcards', when looking up a card ID. 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 from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot) cog = DevToolsCog(mock_bot)
with ( card = unified_card_response(pos_1="CF", player_id=100, card_id=1234)
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 ( with (
patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get,
patch("cogs.dev_tools.db_post", new_callable=AsyncMock), patch("cogs.dev_tools.db_post", new_callable=AsyncMock),
): ):
mock_get.side_effect = [ mock_get.side_effect = [card, refractor_cards_response]
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): with patch.object(cog, "_execute_refractor_test", new_callable=AsyncMock):
await cog.refractor_test.callback(cog, mock_interaction, card_id=5678) await cog.refractor_test.callback(cog, mock_interaction, 1234)
second_call = mock_get.call_args_list[1]
assert "pitchingcards" in str(second_call) # 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): 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. """If cards/{id} returns None, report 'not found' and never call
_execute_refractor_test. The single unified endpoint means only
The command should call edit_original_response with a message containing one db_get is made before the error path.
'not found' and must NOT call _execute_refractor_test.
""" """
from cogs.dev_tools import DevToolsCog from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot) cog = DevToolsCog(mock_bot)
with patch("cogs.dev_tools.db_get", new_callable=AsyncMock, return_value=None): with patch(
"cogs.dev_tools.db_get", new_callable=AsyncMock, return_value=None
) as mock_get:
with patch.object( with patch.object(
cog, "_execute_refractor_test", new_callable=AsyncMock cog, "_execute_refractor_test", new_callable=AsyncMock
) as mock_exec: ) as mock_exec:
await cog.refractor_test.callback(cog, mock_interaction, card_id=9999) await cog.refractor_test.callback(cog, mock_interaction, 9999)
mock_exec.assert_not_called() 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] call_kwargs = mock_interaction.edit_original_response.call_args[1]
assert "not found" in call_kwargs["content"].lower() 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: class TestRefractorTestExecute:
"""Test the execution phase: API calls, step-by-step reporting, """Test the execution phase: API calls, step-by-step reporting,
@ -267,7 +388,13 @@ class TestRefractorTestExecute:
async def test_successful_batter_flow(self, mock_interaction, mock_bot, base_embed): async def test_successful_batter_flow(self, mock_interaction, mock_bot, base_embed):
"""Full happy path: game created, plays inserted, stats updated, """Full happy path: game created, plays inserted, stats updated,
tier-up detected, card rendered.""" tier-up detected, card rendered (200 OK from aiohttp render endpoint).
Previously the render step was stubbed via a dead db_get mock; the
actual render call uses aiohttp.ClientSession directly, so the mock
was never consumed and the test hit the non-fatal except branch
( Card render failed) instead of the happy path.
"""
from cogs.dev_tools import DevToolsCog from cogs.dev_tools import DevToolsCog
cog = DevToolsCog(mock_bot) cog = DevToolsCog(mock_bot)
@ -292,9 +419,23 @@ class TestRefractorTestExecute:
], ],
} }
mock_render_resp = MagicMock()
mock_render_resp.status = 200
# sess.get(url) must return an async context manager (not a coroutine),
# so get_cm is a MagicMock with explicit __aenter__/__aexit__.
get_cm = MagicMock()
get_cm.__aenter__ = AsyncMock(return_value=mock_render_resp)
get_cm.__aexit__ = AsyncMock(return_value=None)
mock_sess = MagicMock()
mock_sess.__aenter__ = AsyncMock(return_value=mock_sess)
mock_sess.__aexit__ = AsyncMock(return_value=None)
mock_sess.get.return_value = get_cm
with ( with (
patch("cogs.dev_tools.db_post", new_callable=AsyncMock) as mock_post, patch("cogs.dev_tools.db_post", new_callable=AsyncMock) as mock_post,
patch("cogs.dev_tools.db_get", new_callable=AsyncMock) as mock_get, patch("cogs.dev_tools.aiohttp.ClientSession", return_value=mock_sess),
): ):
mock_post.side_effect = [ mock_post.side_effect = [
game_response, # POST games game_response, # POST games
@ -303,7 +444,6 @@ class TestRefractorTestExecute:
stats_response, # POST season-stats/update-game stats_response, # POST season-stats/update-game
eval_response, # POST refractor/evaluate-game eval_response, # POST refractor/evaluate-game
] ]
mock_get.return_value = {"image_url": "https://s3.example.com/card.png"}
await cog._execute_refractor_test( await cog._execute_refractor_test(
interaction=mock_interaction, interaction=mock_interaction,
@ -322,6 +462,7 @@ class TestRefractorTestExecute:
result_text = "\n".join(f.value for f in final_embed.fields if f.value) result_text = "\n".join(f.value for f in final_embed.fields if f.value)
assert "" in result_text assert "" in result_text
assert "game" in result_text.lower() assert "game" in result_text.lower()
assert "✅ Card rendered + S3 upload triggered" in result_text
async def test_stops_on_game_creation_failure( async def test_stops_on_game_creation_failure(
self, mock_interaction, mock_bot, base_embed self, mock_interaction, mock_bot, base_embed

View File

@ -12,10 +12,15 @@ class TestTriggerVariantRenders:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_calls_render_url_for_each_tier_up(self): async def test_calls_render_url_for_each_tier_up(self):
"""Each tier-up with variant_created triggers a card render GET request.""" """Each tier-up with variant_created triggers a card render GET request.
card_type field ('sp', 'rp', 'batter') determines the render URL segment
('pitchingcard' or 'battingcard'). This prevents silent wrong-type renders
when track_name strings vary ('Pitcher', 'Starting Pitcher', 'SP', etc.).
"""
tier_ups = [ tier_ups = [
{"player_id": 100, "variant_created": 7, "track_name": "Batter"}, {"player_id": 100, "variant_created": 7, "card_type": "batter"},
{"player_id": 200, "variant_created": 3, "track_name": "Pitcher"}, {"player_id": 200, "variant_created": 3, "card_type": "sp"},
] ]
with patch( with patch(
@ -26,14 +31,20 @@ class TestTriggerVariantRenders:
assert mock_get.call_count == 2 assert mock_get.call_count == 2
call_args_list = [call.args[0] for call in mock_get.call_args_list] call_args_list = [call.args[0] for call in mock_get.call_args_list]
assert any("100" in url and "7" in url for url in call_args_list) assert any(
assert any("200" in url and "3" in url for url in call_args_list) "100" in url and "battingcard" in url and "7" in url
for url in call_args_list
)
assert any(
"200" in url and "pitchingcard" in url and "3" in url
for url in call_args_list
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_tier_ups_without_variant(self): async def test_skips_tier_ups_without_variant(self):
"""Tier-ups without variant_created are skipped.""" """Tier-ups without variant_created are skipped."""
tier_ups = [ tier_ups = [
{"player_id": 100, "track_name": "Batter"}, {"player_id": 100, "card_type": "batter"},
] ]
with patch( with patch(
@ -46,7 +57,7 @@ class TestTriggerVariantRenders:
async def test_api_failure_does_not_raise(self): async def test_api_failure_does_not_raise(self):
"""Render trigger failures are swallowed — fire-and-forget.""" """Render trigger failures are swallowed — fire-and-forget."""
tier_ups = [ tier_ups = [
{"player_id": 100, "variant_created": 7, "track_name": "Batter"}, {"player_id": 100, "variant_created": 7, "card_type": "batter"},
] ]
with patch( with patch(