Merge branch 'main' into feature/wp14-tier-notifications
All checks were successful
Ruff Lint / lint (pull_request) Successful in 14s

This commit is contained in:
cal 2026-03-23 20:26:04 +00:00
commit dc128ad995
7 changed files with 1236 additions and 56 deletions

213
cogs/refractor.py Normal file
View File

@ -0,0 +1,213 @@
"""
Refractor cog /refractor status slash command.
Displays a team's refractor progress: formula value vs next threshold
with a progress bar, paginated 10 cards per page.
Tier names: Base Card (T0) / Base Chrome (T1) / Refractor (T2) /
Gold Refractor (T3) / Superfractor (T4).
Depends on WP-07 (refractor/cards API endpoint).
"""
import logging
from typing import Optional
import discord
from discord import app_commands
from discord.ext import commands
from api_calls import db_get
from helpers.main import get_team_by_owner
logger = logging.getLogger("discord_app")
PAGE_SIZE = 10
TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
FORMULA_LABELS = {
"batter": "PA+TB×2",
"sp": "IP+K",
"rp": "IP+K",
}
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}
def render_progress_bar(current: int, threshold: int, width: int = 10) -> str:
"""
Render a fixed-width ASCII progress bar.
Examples:
render_progress_bar(120, 149) -> '[========--]'
render_progress_bar(0, 100) -> '[----------]'
render_progress_bar(100, 100) -> '[==========]'
"""
if threshold <= 0:
filled = width
else:
ratio = min(current / threshold, 1.0)
filled = round(ratio * width)
empty = width - filled
return f"[{'=' * filled}{'-' * empty}]"
def format_refractor_entry(card_state: dict) -> str:
"""
Format a single card state dict as a display string.
Expected keys: player_name, card_type, current_tier, formula_value,
next_threshold (None if fully evolved).
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the
player name for tiers 1-4. T0 cards have no badge.
Output example:
**[BC] Mike Trout** (Base Chrome)
[========--] 120/149 (PA+TB×2) T1 T2
"""
player_name = card_state.get("player_name", "Unknown")
card_type = card_state.get("card_type", "batter")
current_tier = card_state.get("current_tier", 0)
formula_value = card_state.get("formula_value", 0)
next_threshold = card_state.get("next_threshold")
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
formula_label = FORMULA_LABELS.get(card_type, card_type)
badge = TIER_BADGES.get(current_tier, "")
display_name = f"{badge} {player_name}" if badge else player_name
if current_tier >= 4 or next_threshold is None:
bar = "[==========]"
detail = "FULLY EVOLVED ★"
else:
bar = render_progress_bar(formula_value, next_threshold)
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}"
first_line = f"**{display_name}** ({tier_label})"
second_line = f"{bar} {detail}"
return f"{first_line}\n{second_line}"
def apply_close_filter(card_states: list) -> list:
"""
Return only cards within 80% of their next tier threshold.
Fully evolved cards (T4 or no next_threshold) are excluded.
"""
result = []
for state in card_states:
current_tier = state.get("current_tier", 0)
formula_value = state.get("formula_value", 0)
next_threshold = state.get("next_threshold")
if current_tier >= 4 or not next_threshold:
continue
if formula_value >= 0.8 * next_threshold:
result.append(state)
return result
def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
"""
Slice items for the given 1-indexed page.
Returns (page_items, total_pages). Page is clamped to valid range.
"""
total_pages = max(1, (len(items) + page_size - 1) // page_size)
page = max(1, min(page, total_pages))
start = (page - 1) * page_size
return items[start : start + page_size], total_pages
class Refractor(commands.Cog):
"""Refractor progress tracking slash commands."""
def __init__(self, bot):
self.bot = bot
group_refractor = app_commands.Group(
name="refractor", description="Refractor tracking commands"
)
@group_refractor.command(
name="status", description="Show your team's refractor progress"
)
@app_commands.describe(
card_type="Card type filter (batter, sp, rp)",
season="Season number (default: current)",
tier="Filter by current tier (0-4)",
progress='Use "close" to show cards within 80% of their next tier',
page="Page number (default: 1, 10 cards per page)",
)
async def refractor_status(
self,
interaction: discord.Interaction,
card_type: Optional[str] = None,
season: Optional[int] = None,
tier: Optional[int] = None,
progress: Optional[str] = None,
page: int = 1,
):
"""Show a paginated view of the invoking user's team refractor progress."""
await interaction.response.defer(ephemeral=True)
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.edit_original_response(
content="You don't have a team. Sign up with /newteam first."
)
return
params = [("team_id", team["id"])]
if card_type:
params.append(("card_type", card_type))
if season is not None:
params.append(("season", season))
if tier is not None:
params.append(("tier", tier))
data = await db_get("refractor/cards", params=params)
if not data:
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
items = data if isinstance(data, list) else data.get("cards", [])
if not items:
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
if progress == "close":
items = apply_close_filter(items)
if not items:
await interaction.edit_original_response(
content="No cards are currently close to a tier advancement."
)
return
page_items, total_pages = paginate(items, page)
lines = [format_refractor_entry(state) for state in page_items]
embed = discord.Embed(
title=f"{team['sname']} Refractor Status",
description="\n\n".join(lines),
color=0x6F42C1,
)
embed.set_footer(text=f"Page {page}/{total_pages} · {len(items)} card(s) total")
await interaction.edit_original_response(embed=embed)
async def setup(bot):
await bot.add_cog(Refractor(bot))

View File

@ -4242,6 +4242,24 @@ async def get_game_summary_embed(
return game_embed
async def notify_tier_completion(channel: discord.TextChannel, tier_up: dict) -> None:
"""Stub for WP-14: log evolution tier-up events.
WP-14 will replace this with a full Discord embed notification. For now we
only log the event so that the WP-13 hook has a callable target and the
tier-up data is visible in the application log.
Args:
channel: The Discord channel where the game was played.
tier_up: Dict from the evolution API, expected to contain at minimum
'player_id', 'old_tier', and 'new_tier' keys.
"""
logger.info(
f"[WP-14 stub] notify_tier_completion called for channel={channel.id if channel else 'N/A'} "
f"tier_up={tier_up}"
)
async def complete_game(
session: Session,
interaction: discord.Interaction,
@ -4318,7 +4336,6 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Unable to post decisions to API, rolling back")
# Post game rewards (gauntlet and main team)
try:
win_reward, loss_reward = await post_game_rewards(
@ -4342,6 +4359,26 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Error while posting game rewards")
# Post-game refractor processing (non-blocking)
# WP-13: update season stats then evaluate refractor milestones for all
# participating players. Wrapped in try/except so any failure here is
# non-fatal — the game is already saved and refractor will catch up on the
# next evaluate call.
try:
await db_post(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post(f"refractor/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
# WP-14 will implement full Discord notification; stub for now
logger.info(
f"Refractor tier-up for player {tier_up.get('player_id')}: "
f"{tier_up.get('old_tier')} -> {tier_up.get('new_tier')} "
f"(game {db_game['id']})"
)
await notify_tier_completion(interaction.channel, tier_up)
except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
session.delete(this_play)
session.commit()

View File

@ -2,39 +2,30 @@ import asyncio
import datetime
import logging
import math
import os
import random
import traceback
import discord
import pygsheets
import aiohttp
from discord.ext import commands
from api_calls import *
from bs4 import BeautifulSoup
from difflib import get_close_matches
from dataclasses import dataclass
from typing import Optional, Literal, Union, List
from typing import Optional, Union, List
from exceptions import log_exception
from in_game.gameplay_models import Team
from constants import *
from discord_ui import *
from random_content import *
from utils import (
position_name_to_abbrev,
user_has_role,
get_roster_sheet_legacy,
get_roster_sheet,
get_player_url,
owner_only,
get_cal_user,
get_context_user,
)
from search_utils import *
from .discord_utils import *
# Refractor tier badge prefixes for card embeds (T0 = no badge)
TIER_BADGES = {1: "BC", 2: "R", 3: "GR", 4: "SF"}
async def get_player_photo(player):
search_term = player["bbref_id"] if player["bbref_id"] else player["p_name"]
@ -122,8 +113,18 @@ async def share_channel(channel, user, read_only=False):
async def get_card_embeds(card, include_stats=False) -> list:
tier_badge = ""
try:
evo_state = await db_get(f"evolution/cards/{card['id']}")
if evo_state and evo_state.get("current_tier", 0) > 0:
tier = evo_state["current_tier"]
badge = TIER_BADGES.get(tier)
tier_badge = f"[{badge}] " if badge else ""
except Exception:
pass
embed = discord.Embed(
title=f"{card['player']['p_name']}",
title=f"{tier_badge}{card['player']['p_name']}",
color=int(card["player"]["rarity"]["color"], 16),
)
# embed.description = card['team']['lname']
@ -166,7 +167,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
]
if any(bool_list):
if count == 1:
coll_string = f"Only you"
coll_string = "Only you"
else:
coll_string = (
f"You and {count - 1} other{'s' if count - 1 != 1 else ''}"
@ -174,7 +175,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
elif count:
coll_string = f"{count} other team{'s' if count != 1 else ''}"
else:
coll_string = f"0 teams"
coll_string = "0 teams"
embed.add_field(name="Collected By", value=coll_string)
else:
embed.add_field(
@ -223,7 +224,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
except Exception as e:
logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True
f"could not pull evolution: {e}", exc_info=True, stack_info=True
)
if "420420" not in card["player"]["strat_code"]:
try:
@ -234,7 +235,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
except Exception as e:
logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True
f"could not pull evolution: {e}", exc_info=True, stack_info=True
)
if include_stats:
@ -334,7 +335,7 @@ async def display_cards(
)
try:
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
logger.debug(f"Cards sorted successfully")
logger.debug("Cards sorted successfully")
card_embeds = [await get_card_embeds(x) for x in cards]
logger.debug(f"Created {len(card_embeds)} card embeds")
@ -355,15 +356,15 @@ async def display_cards(
r_emoji = ""
view.left_button.disabled = True
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Close Pack"
view.cancel_button.label = "Close Pack"
view.right_button.label = f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
if len(cards) == 1:
view.right_button.disabled = True
logger.debug(f"Pagination view created successfully")
logger.debug("Pagination view created successfully")
if pack_cover:
logger.debug(f"Sending pack cover message")
logger.debug("Sending pack cover message")
msg = await channel.send(
content=None,
embed=image_embed(pack_cover, title=f"{team['lname']}", desc=pack_name),
@ -375,7 +376,7 @@ async def display_cards(
content=None, embeds=card_embeds[page_num], view=view
)
logger.debug(f"Initial message sent successfully")
logger.debug("Initial message sent successfully")
except Exception as e:
logger.error(
f"Error creating view or sending initial message: {e}", exc_info=True
@ -392,12 +393,12 @@ async def display_cards(
f"{user.mention} you've got {len(cards)} cards here"
)
logger.debug(f"Follow-up message sent successfully")
logger.debug("Follow-up message sent successfully")
except Exception as e:
logger.error(f"Error sending follow-up message: {e}", exc_info=True)
return False
logger.debug(f"Starting main interaction loop")
logger.debug("Starting main interaction loop")
while True:
try:
logger.debug(f"Waiting for user interaction on page {page_num}")
@ -463,7 +464,7 @@ async def display_cards(
),
view=view,
)
logger.debug(f"MVP display updated successfully")
logger.debug("MVP display updated successfully")
except Exception as e:
logger.error(
f"Error processing shiny card on page {page_num}: {e}", exc_info=True
@ -471,19 +472,19 @@ async def display_cards(
# Continue with regular flow instead of crashing
try:
tmp_msg = await channel.send(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
await follow_up.edit(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
await tmp_msg.delete()
except discord.errors.NotFound:
# Role might not exist or message was already deleted
await follow_up.edit(content=f"We've got an MVP!")
await follow_up.edit(content="We've got an MVP!")
except Exception as e:
# Log error but don't crash the function
logger.error(f"Error handling MVP notification: {e}")
await follow_up.edit(content=f"We've got an MVP!")
await follow_up.edit(content="We've got an MVP!")
await view.wait()
view = Pagination([user], timeout=10)
@ -491,7 +492,7 @@ async def display_cards(
view.right_button.label = (
f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
)
view.cancel_button.label = f"Close Pack"
view.cancel_button.label = "Close Pack"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(card_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
@ -539,7 +540,7 @@ async def embed_pagination(
l_emoji = ""
r_emoji = ""
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}"
view.cancel_button.label = f"Cancel"
view.cancel_button.label = "Cancel"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
@ -574,7 +575,7 @@ async def embed_pagination(
view = Pagination([user], timeout=timeout)
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}"
view.cancel_button.label = f"Cancel"
view.cancel_button.label = "Cancel"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
@ -888,7 +889,7 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
timeout=10,
)
if not success:
raise ConnectionError(f"Failed to create this pack of cards.")
raise ConnectionError("Failed to create this pack of cards.")
await db_patch(
"packs",
@ -954,7 +955,7 @@ def get_sheets(bot):
except Exception as e:
logger.error(f"Could not grab sheets auth: {e}")
raise ConnectionError(
f"Bot has not authenticated with discord; please try again in 1 minute."
"Bot has not authenticated with discord; please try again in 1 minute."
)
@ -1064,7 +1065,7 @@ def get_blank_team_card(player):
def get_rosters(team, bot, roster_num: Optional[int] = None) -> list:
sheets = get_sheets(bot)
this_sheet = sheets.open_by_key(team["gsheet"])
r_sheet = this_sheet.worksheet_by_title(f"My Rosters")
r_sheet = this_sheet.worksheet_by_title("My Rosters")
logger.debug(f"this_sheet: {this_sheet} / r_sheet = {r_sheet}")
all_rosters = [None, None, None]
@ -1145,11 +1146,11 @@ def get_roster_lineups(team, bot, roster_num, lineup_num) -> list:
try:
lineup_cells = [(row[0].value, int(row[1].value)) for row in raw_cells]
except ValueError as e:
except ValueError:
logger.error(f"Could not pull roster for {team['abbrev']} due to a ValueError")
raise ValueError(
f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
f"get the card IDs"
"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
"get the card IDs"
)
logger.debug(f"lineup_cells: {lineup_cells}")
@ -1544,7 +1545,7 @@ def get_ratings_guide(sheets):
}
for x in p_data
]
except Exception as e:
except Exception:
return {"valid": False}
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers}
@ -1756,7 +1757,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
pack_ids = await roll_for_cards(all_packs)
if not pack_ids:
logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}")
raise ValueError(f"I was not able to unpack these cards")
raise ValueError("I was not able to unpack these cards")
all_cards = []
for p_id in pack_ids:
@ -1767,7 +1768,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
if not all_cards:
logger.error(f"open_packs - unable to get cards for packs: {pack_ids}")
raise ValueError(f"I was not able to display these cards")
raise ValueError("I was not able to display these cards")
# Present cards to opening channel
if type(context) == commands.Context:
@ -1826,7 +1827,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -1844,7 +1845,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.left_button.disabled = True
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
@ -1887,7 +1888,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1:
@ -1933,7 +1934,7 @@ async def open_choice_pack(
players = pl["players"]
elif pack_type == "Team Choice":
if this_pack["pack_team"] is None:
raise KeyError(f"Team not listed for Team Choice pack")
raise KeyError("Team not listed for Team Choice pack")
d1000 = random.randint(1, 1000)
pack_cover = this_pack["pack_team"]["logo"]
@ -1972,7 +1973,7 @@ async def open_choice_pack(
rarity_id += 1
elif pack_type == "Promo Choice":
if this_pack["pack_cardset"] is None:
raise KeyError(f"Cardset not listed for Promo Choice pack")
raise KeyError("Cardset not listed for Promo Choice pack")
d1000 = random.randint(1, 1000)
pack_cover = IMAGES["mvp-hype"]
@ -2029,8 +2030,8 @@ async def open_choice_pack(
rarity_id += 3
if len(players) == 0:
logger.error(f"Could not create choice pack")
raise ConnectionError(f"Could not create choice pack")
logger.error("Could not create choice pack")
raise ConnectionError("Could not create choice pack")
if type(context) == commands.Context:
author = context.author
@ -2053,7 +2054,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30)
view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -2071,10 +2072,10 @@ async def open_choice_pack(
)
if rarity_id >= 5:
tmp_msg = await pack_channel.send(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
else:
tmp_msg = await pack_channel.send(content=f"We've got a choice pack here!")
tmp_msg = await pack_channel.send(content="We've got a choice pack here!")
while True:
await view.wait()
@ -2089,7 +2090,7 @@ async def open_choice_pack(
)
except Exception as e:
logger.error(f"failed to create cards: {e}")
raise ConnectionError(f"Failed to distribute these cards.")
raise ConnectionError("Failed to distribute these cards.")
await db_patch(
"packs",
@ -2123,7 +2124,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1:

View File

@ -1,5 +1,4 @@
import discord
import datetime
import logging
from logging.handlers import RotatingFileHandler
import asyncio
@ -54,6 +53,7 @@ COGS = [
"cogs.players",
"cogs.gameplay",
"cogs.economy_new.scouting",
"cogs.refractor",
]
intents = discord.Intents.default()

View File

@ -0,0 +1,261 @@
"""
Tests for WP-12: Tier Badge on Card Embed.
Verifies that get_card_embeds() prepends a tier badge to the card title when a
card has Refractor tier progression, and falls back gracefully when the Refractor
API is unavailable or returns no state.
"""
import pytest
from unittest.mock import AsyncMock, patch
import discord
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_card(card_id=1, player_name="Mike Trout", rarity_color="FFD700"):
"""Minimal card dict matching the API shape consumed by get_card_embeds."""
return {
"id": card_id,
"player": {
"player_id": 101,
"p_name": player_name,
"rarity": {"name": "MVP", "value": 5, "color": rarity_color},
"cost": 500,
"image": "https://example.com/card.png",
"image2": None,
"mlbclub": "Los Angeles Angels",
"franchise": "Los Angeles Angels",
"headshot": "https://example.com/headshot.jpg",
"cardset": {"name": "2023 Season"},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"bbref_id": "troutmi01",
"strat_code": "420420",
"fangr_id": None,
"vanity_card": None,
},
"team": {
"id": 10,
"lname": "Paper Dynasty",
"logo": "https://example.com/logo.png",
"season": 7,
},
}
def _make_paperdex():
"""Minimal paperdex response."""
return {"count": 0, "paperdex": []}
# ---------------------------------------------------------------------------
# Helpers to patch the async dependencies of get_card_embeds
# ---------------------------------------------------------------------------
def _patch_db_get(evo_response=None, paperdex_response=None):
"""
Return a side_effect callable that routes db_get calls to the right mock
responses, so other get_card_embeds internals still behave.
"""
if paperdex_response is None:
paperdex_response = _make_paperdex()
async def _side_effect(endpoint, *args, **kwargs):
if str(endpoint).startswith("evolution/cards/"):
return evo_response
if endpoint == "paperdex":
return paperdex_response
# Fallback for any other endpoint (e.g. plays/batting, plays/pitching)
return None
return _side_effect
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestTierBadgeFormat:
"""Unit: tier badge string format for each tier level."""
@pytest.mark.asyncio
async def test_tier_zero_no_badge(self):
"""T0 evolution state (current_tier=0) should produce no badge in title."""
card = _make_card()
evo_state = {"current_tier": 0, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_tier_one_badge(self):
"""current_tier=1 should prefix title with [BC] (Base Chrome)."""
card = _make_card()
evo_state = {"current_tier": 1, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[BC] Mike Trout"
@pytest.mark.asyncio
async def test_tier_two_badge(self):
"""current_tier=2 should prefix title with [R] (Refractor)."""
card = _make_card()
evo_state = {"current_tier": 2, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[R] Mike Trout"
@pytest.mark.asyncio
async def test_tier_three_badge(self):
"""current_tier=3 should prefix title with [GR] (Gold Refractor)."""
card = _make_card()
evo_state = {"current_tier": 3, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[GR] Mike Trout"
@pytest.mark.asyncio
async def test_tier_four_superfractor_badge(self):
"""current_tier=4 (Superfractor) should prefix title with [SF]."""
card = _make_card()
evo_state = {"current_tier": 4, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[SF] Mike Trout"
class TestTierBadgeInTitle:
"""Unit: badge appears correctly in the embed title."""
@pytest.mark.asyncio
async def test_badge_prepended_to_player_name(self):
"""Badge should be prepended so title reads '[Tx] <player_name>'."""
card = _make_card(player_name="Juan Soto")
evo_state = {"current_tier": 2, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title.startswith("[R] ")
assert "Juan Soto" in embeds[0].title
class TestFullyEvolvedBadge:
"""Unit: fully evolved card shows [SF] badge (Superfractor)."""
@pytest.mark.asyncio
async def test_fully_evolved_badge(self):
"""T4 card should show [SF] prefix, not [T4]."""
card = _make_card()
evo_state = {"current_tier": 4}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title.startswith("[SF] ")
assert "[T4]" not in embeds[0].title
class TestNoBadgeGracefulFallback:
"""Unit: embed renders correctly when evolution state is absent or API fails."""
@pytest.mark.asyncio
async def test_no_evolution_state_no_badge(self):
"""When evolution API returns None (404), title has no badge."""
card = _make_card()
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=None)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_api_exception_no_badge(self):
"""When evolution API raises an exception, card display is unaffected."""
card = _make_card()
async def _failing_db_get(endpoint, *args, **kwargs):
if str(endpoint).startswith("evolution/cards/"):
raise ConnectionError("API unreachable")
if endpoint == "paperdex":
return _make_paperdex()
return None
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _failing_db_get
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
class TestEmbedColorUnchanged:
"""Unit: embed color comes from card rarity, not affected by evolution state."""
@pytest.mark.asyncio
async def test_embed_color_from_rarity_with_evolution(self):
"""Color is still derived from rarity even when a tier badge is present."""
rarity_color = "FF0000"
card = _make_card(rarity_color=rarity_color)
evo_state = {"current_tier": 2}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].color == discord.Color(int(rarity_color, 16))
@pytest.mark.asyncio
async def test_embed_color_from_rarity_without_evolution(self):
"""Color is derived from rarity when no evolution state exists."""
rarity_color = "00FF00"
card = _make_card(rarity_color=rarity_color)
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=None)
embeds = await _call_get_card_embeds(card)
assert embeds[0].color == discord.Color(int(rarity_color, 16))
# ---------------------------------------------------------------------------
# Helper: call get_card_embeds and return embed list
# ---------------------------------------------------------------------------
async def _call_get_card_embeds(card):
"""Import and call get_card_embeds, returning the list of embeds."""
from helpers.main import get_card_embeds
result = await get_card_embeds(card)
if isinstance(result, list):
return result
return [result]

View File

@ -0,0 +1,201 @@
"""
Tests for the WP-13 post-game callback integration hook.
These tests verify that after a game is saved to the API, two additional
POST requests are fired in the correct order:
1. POST season-stats/update-game/{game_id} update player_season_stats
2. POST refractor/evaluate-game/{game_id} evaluate refractor milestones
Key design constraints being tested:
- Season stats MUST be updated before refractor is evaluated (ordering).
- Failure of either refractor call must NOT propagate the game result has
already been committed; refractor will self-heal on the next evaluate pass.
- Tier-up dicts returned by the refractor endpoint are passed to
notify_tier_completion so WP-14 can present them to the player.
"""
import asyncio
import logging
import pytest
from unittest.mock import AsyncMock, MagicMock, call, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_channel(channel_id: int = 999) -> MagicMock:
ch = MagicMock()
ch.id = channel_id
return ch
async def _run_hook(db_post_mock, db_game_id: int = 42):
"""
Execute the post-game hook in isolation.
We import the hook logic inline rather than calling the full
complete_game() function (which requires a live DB session, Discord
interaction, and Play object). The hook is a self-contained try/except
block so we replicate it verbatim here to test its behaviour.
"""
channel = _make_channel()
from command_logic.logic_gameplay import notify_tier_completion
db_game = {"id": db_game_id}
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
await notify_tier_completion(channel, tier_up)
except Exception:
pass # non-fatal — mirrors the logger.warning in production
return channel
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hook_posts_to_both_endpoints_in_order():
"""
Both refractor endpoints are called, and season-stats comes first.
The ordering is critical: player_season_stats must be populated before the
refractor engine tries to read them for milestone evaluation.
"""
db_post_mock = AsyncMock(return_value={})
await _run_hook(db_post_mock, db_game_id=42)
assert db_post_mock.call_count == 2
calls = db_post_mock.call_args_list
# First call must be season-stats
assert calls[0] == call("season-stats/update-game/42")
# Second call must be refractor evaluate
assert calls[1] == call("refractor/evaluate-game/42")
@pytest.mark.asyncio
async def test_hook_is_nonfatal_when_db_post_raises():
"""
A failure inside the hook must not raise to the caller.
The game result is already persisted when the hook runs. If the refractor
API is down or returns an error, we log a warning and continue the game
completion flow must not be interrupted.
"""
db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable"))
# Should not raise
try:
await _run_hook(db_post_mock, db_game_id=7)
except Exception as exc:
pytest.fail(f"Hook raised unexpectedly: {exc}")
@pytest.mark.asyncio
async def test_hook_processes_tier_ups_from_evo_result():
"""
When the refractor endpoint returns tier_ups, each entry is forwarded to
notify_tier_completion.
This confirms the data path between the API response and the WP-14
notification stub so that WP-14 only needs to replace the stub body.
"""
tier_ups = [
{"player_id": 101, "old_tier": 1, "new_tier": 2},
{"player_id": 202, "old_tier": 2, "new_tier": 3},
]
async def fake_db_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": tier_ups}
return {}
db_post_mock = AsyncMock(side_effect=fake_db_post)
with patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify:
channel = _make_channel()
db_game = {"id": 99}
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
await mock_notify(channel, tier_up)
except Exception:
pass
assert mock_notify.call_count == 2
# Verify both tier_up dicts were forwarded
forwarded = [c.args[1] for c in mock_notify.call_args_list]
assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded
assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded
@pytest.mark.asyncio
async def test_hook_no_tier_ups_does_not_call_notify():
"""
When the refractor response has no tier_ups (empty list or missing key),
notify_tier_completion is never called.
Avoids spurious Discord messages for routine game completions.
"""
async def fake_db_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": []}
return {}
db_post_mock = AsyncMock(side_effect=fake_db_post)
with patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify:
channel = _make_channel()
db_game = {"id": 55}
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
await mock_notify(channel, tier_up)
except Exception:
pass
mock_notify.assert_not_called()
@pytest.mark.asyncio
async def test_notify_tier_completion_stub_logs_and_does_not_raise(caplog):
"""
The WP-14 stub must log the event and return cleanly.
Verifies the contract that WP-14 can rely on: the function accepts
(channel, tier_up) and does not raise, so the hook's for-loop is safe.
"""
from command_logic.logic_gameplay import notify_tier_completion
channel = _make_channel(channel_id=123)
tier_up = {"player_id": 77, "old_tier": 0, "new_tier": 1}
with caplog.at_level(logging.INFO):
await notify_tier_completion(channel, tier_up)
# At minimum one log message should reference the channel or tier_up data
assert any(
"notify_tier_completion" in rec.message or "77" in rec.message
for rec in caplog.records
)

View File

@ -0,0 +1,467 @@
"""
Unit tests for refractor command helper functions (WP-11).
Tests cover:
- render_progress_bar: ASCII bar rendering at various fill levels
- format_refractor_entry: Full card state formatting including fully evolved case
- apply_close_filter: 80% proximity filter logic
- paginate: 1-indexed page slicing and total-page calculation
- TIER_NAMES: Display names for all tiers
- Slash command: empty roster and no-team responses (async, uses mocks)
All tests are pure-unit unless marked otherwise; no network calls are made.
"""
import sys
import os
import pytest
from unittest.mock import AsyncMock, Mock, patch
import discord
from discord.ext import commands
# Make the repo root importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cogs.refractor import (
render_progress_bar,
format_refractor_entry,
apply_close_filter,
paginate,
TIER_NAMES,
TIER_BADGES,
PAGE_SIZE,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def batter_state():
"""A mid-progress batter card state."""
return {
"player_name": "Mike Trout",
"card_type": "batter",
"current_tier": 1,
"formula_value": 120,
"next_threshold": 149,
}
@pytest.fixture
def evolved_state():
"""A fully evolved card state (T4)."""
return {
"player_name": "Shohei Ohtani",
"card_type": "batter",
"current_tier": 4,
"formula_value": 300,
"next_threshold": None,
}
@pytest.fixture
def sp_state():
"""A starting pitcher card state at T2."""
return {
"player_name": "Sandy Alcantara",
"card_type": "sp",
"current_tier": 2,
"formula_value": 95,
"next_threshold": 120,
}
# ---------------------------------------------------------------------------
# render_progress_bar
# ---------------------------------------------------------------------------
class TestRenderProgressBar:
"""
Tests for render_progress_bar().
Verifies width, fill character, empty character, boundary conditions,
and clamping when current exceeds threshold.
"""
def test_empty_bar(self):
"""current=0 → all dashes."""
assert render_progress_bar(0, 100) == "[----------]"
def test_full_bar(self):
"""current == threshold → all equals."""
assert render_progress_bar(100, 100) == "[==========]"
def test_partial_fill(self):
"""120/149 ≈ 80.5% → 8 filled of 10."""
bar = render_progress_bar(120, 149)
assert bar == "[========--]"
def test_half_fill(self):
"""50/100 = 50% → 5 filled."""
assert render_progress_bar(50, 100) == "[=====-----]"
def test_over_threshold_clamps_to_full(self):
"""current > threshold should not overflow the bar."""
assert render_progress_bar(200, 100) == "[==========]"
def test_zero_threshold_returns_full_bar(self):
"""threshold=0 avoids division by zero and returns full bar."""
assert render_progress_bar(0, 0) == "[==========]"
def test_custom_width(self):
"""Width parameter controls bar length."""
bar = render_progress_bar(5, 10, width=4)
assert bar == "[==--]"
# ---------------------------------------------------------------------------
# format_refractor_entry
# ---------------------------------------------------------------------------
class TestFormatRefractorEntry:
"""
Tests for format_refractor_entry().
Verifies player name, tier label, progress bar, formula label,
and the special fully-evolved formatting.
"""
def test_player_name_in_output(self, batter_state):
"""Player name appears bold in the first line (badge may prefix it)."""
result = format_refractor_entry(batter_state)
assert "Mike Trout" in result
assert "**" in result
def test_tier_label_in_output(self, batter_state):
"""Current tier name (Base Chrome for T1) appears in output."""
result = format_refractor_entry(batter_state)
assert "(Base Chrome)" in result
def test_progress_values_in_output(self, batter_state):
"""current/threshold values appear in output."""
result = format_refractor_entry(batter_state)
assert "120/149" in result
def test_formula_label_batter(self, batter_state):
"""Batter formula label PA+TB×2 appears in output."""
result = format_refractor_entry(batter_state)
assert "PA+TB×2" in result
def test_tier_progression_arrow(self, batter_state):
"""T1 → T2 arrow progression appears for non-evolved cards."""
result = format_refractor_entry(batter_state)
assert "T1 → T2" in result
def test_sp_formula_label(self, sp_state):
"""SP formula label IP+K appears for starting pitchers."""
result = format_refractor_entry(sp_state)
assert "IP+K" in result
def test_fully_evolved_no_threshold(self, evolved_state):
"""T4 card with next_threshold=None shows FULLY EVOLVED."""
result = format_refractor_entry(evolved_state)
assert "FULLY EVOLVED" in result
def test_fully_evolved_by_tier(self, batter_state):
"""current_tier=4 triggers fully evolved display even with a threshold."""
batter_state["current_tier"] = 4
batter_state["next_threshold"] = 200
result = format_refractor_entry(batter_state)
assert "FULLY EVOLVED" in result
def test_fully_evolved_no_arrow(self, evolved_state):
"""Fully evolved cards don't show a tier arrow."""
result = format_refractor_entry(evolved_state)
assert "" not in result
def test_two_line_output(self, batter_state):
"""Output always has exactly two lines (name line + bar line)."""
result = format_refractor_entry(batter_state)
lines = result.split("\n")
assert len(lines) == 2
# ---------------------------------------------------------------------------
# TIER_BADGES
# ---------------------------------------------------------------------------
class TestTierBadges:
"""
Verify TIER_BADGES values and that format_refractor_entry prepends badges
correctly for T1-T4. T0 cards should have no badge prefix.
"""
def test_t1_badge_value(self):
"""T1 badge is [BC] (Base Chrome)."""
assert TIER_BADGES[1] == "[BC]"
def test_t2_badge_value(self):
"""T2 badge is [R] (Refractor)."""
assert TIER_BADGES[2] == "[R]"
def test_t3_badge_value(self):
"""T3 badge is [GR] (Gold Refractor)."""
assert TIER_BADGES[3] == "[GR]"
def test_t4_badge_value(self):
"""T4 badge is [SF] (Superfractor)."""
assert TIER_BADGES[4] == "[SF]"
def test_t0_no_badge(self):
"""T0 has no badge entry in TIER_BADGES."""
assert 0 not in TIER_BADGES
def test_format_entry_t1_badge_present(self, batter_state):
"""format_refractor_entry prepends [BC] badge for T1 cards."""
result = format_refractor_entry(batter_state)
assert "[BC]" in result
def test_format_entry_t2_badge_present(self, sp_state):
"""format_refractor_entry prepends [R] badge for T2 cards."""
result = format_refractor_entry(sp_state)
assert "[R]" in result
def test_format_entry_t4_badge_present(self, evolved_state):
"""format_refractor_entry prepends [SF] badge for T4 cards."""
result = format_refractor_entry(evolved_state)
assert "[SF]" in result
def test_format_entry_t0_no_badge(self):
"""format_refractor_entry does not prepend any badge for T0 cards."""
state = {
"player_name": "Rookie Player",
"card_type": "batter",
"current_tier": 0,
"formula_value": 10,
"next_threshold": 50,
}
result = format_refractor_entry(state)
assert "[BC]" not in result
assert "[R]" not in result
assert "[GR]" not in result
assert "[SF]" not in result
def test_format_entry_badge_before_name(self, batter_state):
"""Badge appears before the player name in the bold section."""
result = format_refractor_entry(batter_state)
first_line = result.split("\n")[0]
badge_pos = first_line.find("[BC]")
name_pos = first_line.find("Mike Trout")
assert badge_pos < name_pos
# ---------------------------------------------------------------------------
# apply_close_filter
# ---------------------------------------------------------------------------
class TestApplyCloseFilter:
"""
Tests for apply_close_filter().
'Close' means formula_value >= 80% of next_threshold.
Fully evolved (T4 or no threshold) cards are excluded from results.
"""
def test_close_card_included(self):
"""Card at exactly 80% is included."""
state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100}
assert apply_close_filter([state]) == [state]
def test_above_80_percent_included(self):
"""Card above 80% is included."""
state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100}
assert apply_close_filter([state]) == [state]
def test_below_80_percent_excluded(self):
"""Card below 80% threshold is excluded."""
state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100}
assert apply_close_filter([state]) == []
def test_fully_evolved_excluded(self):
"""T4 cards are never returned by close filter."""
state = {"current_tier": 4, "formula_value": 300, "next_threshold": None}
assert apply_close_filter([state]) == []
def test_none_threshold_excluded(self):
"""Cards with no next_threshold (regardless of tier) are excluded."""
state = {"current_tier": 3, "formula_value": 200, "next_threshold": None}
assert apply_close_filter([state]) == []
def test_mixed_list(self):
"""Only qualifying cards are returned from a mixed list."""
close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100}
not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100}
evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None}
result = apply_close_filter([close, not_close, evolved])
assert result == [close]
def test_empty_list(self):
"""Empty input returns empty list."""
assert apply_close_filter([]) == []
# ---------------------------------------------------------------------------
# paginate
# ---------------------------------------------------------------------------
class TestPaginate:
"""
Tests for paginate().
Verifies 1-indexed page slicing, total page count calculation,
page clamping, and PAGE_SIZE default.
"""
def _items(self, n):
return list(range(n))
def test_single_page_all_items(self):
"""Fewer items than page size returns all on page 1."""
items, total = paginate(self._items(5), page=1)
assert items == [0, 1, 2, 3, 4]
assert total == 1
def test_first_page(self):
"""Page 1 returns first PAGE_SIZE items."""
items, total = paginate(self._items(25), page=1)
assert items == list(range(10))
assert total == 3
def test_second_page(self):
"""Page 2 returns next PAGE_SIZE items."""
items, total = paginate(self._items(25), page=2)
assert items == list(range(10, 20))
def test_last_page_partial(self):
"""Last page returns remaining items (fewer than PAGE_SIZE)."""
items, total = paginate(self._items(25), page=3)
assert items == [20, 21, 22, 23, 24]
assert total == 3
def test_page_clamp_low(self):
"""Page 0 or negative is clamped to page 1."""
items, _ = paginate(self._items(15), page=0)
assert items == list(range(10))
def test_page_clamp_high(self):
"""Page beyond total is clamped to last page."""
items, total = paginate(self._items(15), page=99)
assert items == [10, 11, 12, 13, 14]
assert total == 2
def test_empty_list_returns_empty_page(self):
"""Empty input returns empty page with total_pages=1."""
items, total = paginate([], page=1)
assert items == []
assert total == 1
def test_exact_page_boundary(self):
"""Exactly PAGE_SIZE items → 1 full page."""
items, total = paginate(self._items(PAGE_SIZE), page=1)
assert len(items) == PAGE_SIZE
assert total == 1
# ---------------------------------------------------------------------------
# TIER_NAMES
# ---------------------------------------------------------------------------
class TestTierNames:
"""
Verify all tier display names are correctly defined.
T0=Base Card, T1=Base Chrome, T2=Refractor, T3=Gold Refractor, T4=Superfractor
"""
def test_t0_base_card(self):
assert TIER_NAMES[0] == "Base Card"
def test_t1_base_chrome(self):
assert TIER_NAMES[1] == "Base Chrome"
def test_t2_refractor(self):
assert TIER_NAMES[2] == "Refractor"
def test_t3_gold_refractor(self):
assert TIER_NAMES[3] == "Gold Refractor"
def test_t4_superfractor(self):
assert TIER_NAMES[4] == "Superfractor"
# ---------------------------------------------------------------------------
# Slash command: empty roster / no-team scenarios
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_bot():
bot = AsyncMock(spec=commands.Bot)
return bot
@pytest.fixture
def mock_interaction():
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.edit_original_response = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
return interaction
@pytest.mark.asyncio
async def test_refractor_status_no_team(mock_bot, mock_interaction):
"""
When the user has no team, the command replies with a signup prompt
and does not call db_get.
Why: get_team_by_owner returning None means the user is unregistered;
the command must short-circuit before hitting the API.
"""
from cogs.refractor import Refractor
cog = Refractor(mock_bot)
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=None)):
with patch("cogs.refractor.db_get", new=AsyncMock()) as mock_db:
await cog.refractor_status.callback(cog, mock_interaction)
mock_db.assert_not_called()
call_kwargs = mock_interaction.edit_original_response.call_args
content = call_kwargs.kwargs.get("content", "")
assert "newteam" in content.lower() or "team" in content.lower()
@pytest.mark.asyncio
async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
"""
When the API returns an empty card list, the command sends an
informative 'no data' message rather than an empty embed.
Why: An empty list is valid (team has no refractor cards yet);
the command should not crash or send a blank embed.
"""
from cogs.refractor import Refractor
cog = Refractor(mock_bot)
team = {"id": 1, "sname": "Test"}
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
with patch("cogs.refractor.db_get", new=AsyncMock(return_value={"cards": []})):
await cog.refractor_status.callback(cog, mock_interaction)
call_kwargs = mock_interaction.edit_original_response.call_args
content = call_kwargs.kwargs.get("content", "")
assert "no refractor data" in content.lower()