Merge branch 'main' into feature/wp14-tier-notifications
All checks were successful
Ruff Lint / lint (pull_request) Successful in 14s
All checks were successful
Ruff Lint / lint (pull_request) Successful in 14s
This commit is contained in:
commit
dc128ad995
213
cogs/refractor.py
Normal file
213
cogs/refractor.py
Normal 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))
|
||||||
@ -4242,6 +4242,24 @@ async def get_game_summary_embed(
|
|||||||
return game_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(
|
async def complete_game(
|
||||||
session: Session,
|
session: Session,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
@ -4318,7 +4336,6 @@ async def complete_game(
|
|||||||
await roll_back(db_game["id"], plays=True, decisions=True)
|
await roll_back(db_game["id"], plays=True, decisions=True)
|
||||||
log_exception(e, msg="Unable to post decisions to API, rolling back")
|
log_exception(e, msg="Unable to post decisions to API, rolling back")
|
||||||
|
|
||||||
|
|
||||||
# Post game rewards (gauntlet and main team)
|
# Post game rewards (gauntlet and main team)
|
||||||
try:
|
try:
|
||||||
win_reward, loss_reward = await post_game_rewards(
|
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)
|
await roll_back(db_game["id"], plays=True, decisions=True)
|
||||||
log_exception(e, msg="Error while posting game rewards")
|
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.delete(this_play)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
109
helpers/main.py
109
helpers/main.py
@ -2,39 +2,30 @@ import asyncio
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
|
||||||
import random
|
import random
|
||||||
import traceback
|
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
import pygsheets
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from api_calls import *
|
from api_calls import *
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from difflib import get_close_matches
|
from typing import Optional, Union, List
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional, Literal, Union, List
|
|
||||||
|
|
||||||
from exceptions import log_exception
|
|
||||||
from in_game.gameplay_models import Team
|
from in_game.gameplay_models import Team
|
||||||
from constants import *
|
from constants import *
|
||||||
from discord_ui import *
|
from discord_ui import *
|
||||||
from random_content import *
|
from random_content import *
|
||||||
from utils import (
|
from utils import (
|
||||||
position_name_to_abbrev,
|
|
||||||
user_has_role,
|
|
||||||
get_roster_sheet_legacy,
|
|
||||||
get_roster_sheet,
|
get_roster_sheet,
|
||||||
get_player_url,
|
|
||||||
owner_only,
|
|
||||||
get_cal_user,
|
get_cal_user,
|
||||||
get_context_user,
|
|
||||||
)
|
)
|
||||||
from search_utils import *
|
from search_utils import *
|
||||||
from .discord_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):
|
async def get_player_photo(player):
|
||||||
search_term = player["bbref_id"] if player["bbref_id"] else player["p_name"]
|
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:
|
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(
|
embed = discord.Embed(
|
||||||
title=f"{card['player']['p_name']}",
|
title=f"{tier_badge}{card['player']['p_name']}",
|
||||||
color=int(card["player"]["rarity"]["color"], 16),
|
color=int(card["player"]["rarity"]["color"], 16),
|
||||||
)
|
)
|
||||||
# embed.description = card['team']['lname']
|
# embed.description = card['team']['lname']
|
||||||
@ -166,7 +167,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
|
|||||||
]
|
]
|
||||||
if any(bool_list):
|
if any(bool_list):
|
||||||
if count == 1:
|
if count == 1:
|
||||||
coll_string = f"Only you"
|
coll_string = "Only you"
|
||||||
else:
|
else:
|
||||||
coll_string = (
|
coll_string = (
|
||||||
f"You and {count - 1} other{'s' if count - 1 != 1 else ''}"
|
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:
|
elif count:
|
||||||
coll_string = f"{count} other team{'s' if count != 1 else ''}"
|
coll_string = f"{count} other team{'s' if count != 1 else ''}"
|
||||||
else:
|
else:
|
||||||
coll_string = f"0 teams"
|
coll_string = "0 teams"
|
||||||
embed.add_field(name="Collected By", value=coll_string)
|
embed.add_field(name="Collected By", value=coll_string)
|
||||||
else:
|
else:
|
||||||
embed.add_field(
|
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']}")
|
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
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"]:
|
if "420420" not in card["player"]["strat_code"]:
|
||||||
try:
|
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']}")
|
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
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:
|
if include_stats:
|
||||||
@ -334,7 +335,7 @@ async def display_cards(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
|
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]
|
card_embeds = [await get_card_embeds(x) for x in cards]
|
||||||
logger.debug(f"Created {len(card_embeds)} card embeds")
|
logger.debug(f"Created {len(card_embeds)} card embeds")
|
||||||
@ -355,15 +356,15 @@ async def display_cards(
|
|||||||
r_emoji = "→"
|
r_emoji = "→"
|
||||||
view.left_button.disabled = True
|
view.left_button.disabled = True
|
||||||
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
|
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}"
|
view.right_button.label = f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
|
||||||
if len(cards) == 1:
|
if len(cards) == 1:
|
||||||
view.right_button.disabled = True
|
view.right_button.disabled = True
|
||||||
|
|
||||||
logger.debug(f"Pagination view created successfully")
|
logger.debug("Pagination view created successfully")
|
||||||
|
|
||||||
if pack_cover:
|
if pack_cover:
|
||||||
logger.debug(f"Sending pack cover message")
|
logger.debug("Sending pack cover message")
|
||||||
msg = await channel.send(
|
msg = await channel.send(
|
||||||
content=None,
|
content=None,
|
||||||
embed=image_embed(pack_cover, title=f"{team['lname']}", desc=pack_name),
|
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
|
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:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error creating view or sending initial message: {e}", exc_info=True
|
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"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error sending follow-up message: {e}", exc_info=True)
|
logger.error(f"Error sending follow-up message: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.debug(f"Starting main interaction loop")
|
logger.debug("Starting main interaction loop")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Waiting for user interaction on page {page_num}")
|
logger.debug(f"Waiting for user interaction on page {page_num}")
|
||||||
@ -463,7 +464,7 @@ async def display_cards(
|
|||||||
),
|
),
|
||||||
view=view,
|
view=view,
|
||||||
)
|
)
|
||||||
logger.debug(f"MVP display updated successfully")
|
logger.debug("MVP display updated successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error processing shiny card on page {page_num}: {e}", exc_info=True
|
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
|
# Continue with regular flow instead of crashing
|
||||||
try:
|
try:
|
||||||
tmp_msg = await channel.send(
|
tmp_msg = await channel.send(
|
||||||
content=f"<@&1163537676885033010> we've got an MVP!"
|
content="<@&1163537676885033010> we've got an MVP!"
|
||||||
)
|
)
|
||||||
await follow_up.edit(
|
await follow_up.edit(
|
||||||
content=f"<@&1163537676885033010> we've got an MVP!"
|
content="<@&1163537676885033010> we've got an MVP!"
|
||||||
)
|
)
|
||||||
await tmp_msg.delete()
|
await tmp_msg.delete()
|
||||||
except discord.errors.NotFound:
|
except discord.errors.NotFound:
|
||||||
# Role might not exist or message was already deleted
|
# 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:
|
except Exception as e:
|
||||||
# Log error but don't crash the function
|
# Log error but don't crash the function
|
||||||
logger.error(f"Error handling MVP notification: {e}")
|
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()
|
await view.wait()
|
||||||
|
|
||||||
view = Pagination([user], timeout=10)
|
view = Pagination([user], timeout=10)
|
||||||
@ -491,7 +492,7 @@ async def display_cards(
|
|||||||
view.right_button.label = (
|
view.right_button.label = (
|
||||||
f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
|
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)}"
|
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(card_embeds)}"
|
||||||
if page_num == 0:
|
if page_num == 0:
|
||||||
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
|
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
|
||||||
@ -539,7 +540,7 @@ async def embed_pagination(
|
|||||||
l_emoji = ""
|
l_emoji = ""
|
||||||
r_emoji = ""
|
r_emoji = ""
|
||||||
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{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)}"
|
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
|
||||||
if page_num == 0:
|
if page_num == 0:
|
||||||
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
|
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 = Pagination([user], timeout=timeout)
|
||||||
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{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)}"
|
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
|
||||||
if page_num == 0:
|
if page_num == 0:
|
||||||
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
|
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,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if not success:
|
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(
|
await db_patch(
|
||||||
"packs",
|
"packs",
|
||||||
@ -954,7 +955,7 @@ def get_sheets(bot):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Could not grab sheets auth: {e}")
|
logger.error(f"Could not grab sheets auth: {e}")
|
||||||
raise ConnectionError(
|
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:
|
def get_rosters(team, bot, roster_num: Optional[int] = None) -> list:
|
||||||
sheets = get_sheets(bot)
|
sheets = get_sheets(bot)
|
||||||
this_sheet = sheets.open_by_key(team["gsheet"])
|
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}")
|
logger.debug(f"this_sheet: {this_sheet} / r_sheet = {r_sheet}")
|
||||||
|
|
||||||
all_rosters = [None, None, None]
|
all_rosters = [None, None, None]
|
||||||
@ -1145,11 +1146,11 @@ def get_roster_lineups(team, bot, roster_num, lineup_num) -> list:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
lineup_cells = [(row[0].value, int(row[1].value)) for row in raw_cells]
|
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")
|
logger.error(f"Could not pull roster for {team['abbrev']} due to a ValueError")
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
|
"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
|
||||||
f"get the card IDs"
|
"get the card IDs"
|
||||||
)
|
)
|
||||||
logger.debug(f"lineup_cells: {lineup_cells}")
|
logger.debug(f"lineup_cells: {lineup_cells}")
|
||||||
|
|
||||||
@ -1544,7 +1545,7 @@ def get_ratings_guide(sheets):
|
|||||||
}
|
}
|
||||||
for x in p_data
|
for x in p_data
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"valid": False}
|
return {"valid": False}
|
||||||
|
|
||||||
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers}
|
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)
|
pack_ids = await roll_for_cards(all_packs)
|
||||||
if not pack_ids:
|
if not pack_ids:
|
||||||
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(f"I was not able to unpack these cards")
|
raise ValueError("I was not able to unpack these cards")
|
||||||
|
|
||||||
all_cards = []
|
all_cards = []
|
||||||
for p_id in pack_ids:
|
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:
|
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}")
|
||||||
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
|
# Present cards to opening channel
|
||||||
if type(context) == commands.Context:
|
if type(context) == commands.Context:
|
||||||
@ -1826,7 +1827,7 @@ async def get_choice_from_cards(
|
|||||||
view = Pagination([interaction.user], timeout=30)
|
view = Pagination([interaction.user], timeout=30)
|
||||||
view.left_button.disabled = True
|
view.left_button.disabled = True
|
||||||
view.left_button.label = f"Prev: -/{len(card_embeds)}"
|
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.style = discord.ButtonStyle.success
|
||||||
view.cancel_button.disabled = True
|
view.cancel_button.disabled = True
|
||||||
view.right_button.label = f"Next: 1/{len(card_embeds)}"
|
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 = Pagination([interaction.user], timeout=30)
|
||||||
view.left_button.label = f"Prev: -/{len(card_embeds)}"
|
view.left_button.label = f"Prev: -/{len(card_embeds)}"
|
||||||
view.left_button.disabled = True
|
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.cancel_button.style = discord.ButtonStyle.success
|
||||||
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
|
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 = Pagination([interaction.user], timeout=30)
|
||||||
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
|
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.cancel_button.style = discord.ButtonStyle.success
|
||||||
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
|
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
|
||||||
if page_num == 1:
|
if page_num == 1:
|
||||||
@ -1933,7 +1934,7 @@ async def open_choice_pack(
|
|||||||
players = pl["players"]
|
players = pl["players"]
|
||||||
elif pack_type == "Team Choice":
|
elif pack_type == "Team Choice":
|
||||||
if this_pack["pack_team"] is None:
|
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)
|
d1000 = random.randint(1, 1000)
|
||||||
pack_cover = this_pack["pack_team"]["logo"]
|
pack_cover = this_pack["pack_team"]["logo"]
|
||||||
@ -1972,7 +1973,7 @@ async def open_choice_pack(
|
|||||||
rarity_id += 1
|
rarity_id += 1
|
||||||
elif pack_type == "Promo Choice":
|
elif pack_type == "Promo Choice":
|
||||||
if this_pack["pack_cardset"] is None:
|
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)
|
d1000 = random.randint(1, 1000)
|
||||||
pack_cover = IMAGES["mvp-hype"]
|
pack_cover = IMAGES["mvp-hype"]
|
||||||
@ -2029,8 +2030,8 @@ async def open_choice_pack(
|
|||||||
rarity_id += 3
|
rarity_id += 3
|
||||||
|
|
||||||
if len(players) == 0:
|
if len(players) == 0:
|
||||||
logger.error(f"Could not create choice pack")
|
logger.error("Could not create choice pack")
|
||||||
raise ConnectionError(f"Could not create choice pack")
|
raise ConnectionError("Could not create choice pack")
|
||||||
|
|
||||||
if type(context) == commands.Context:
|
if type(context) == commands.Context:
|
||||||
author = context.author
|
author = context.author
|
||||||
@ -2053,7 +2054,7 @@ async def open_choice_pack(
|
|||||||
view = Pagination([author], timeout=30)
|
view = Pagination([author], timeout=30)
|
||||||
view.left_button.disabled = True
|
view.left_button.disabled = True
|
||||||
view.left_button.label = f"Prev: -/{len(card_embeds)}"
|
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.style = discord.ButtonStyle.success
|
||||||
view.cancel_button.disabled = True
|
view.cancel_button.disabled = True
|
||||||
view.right_button.label = f"Next: 1/{len(card_embeds)}"
|
view.right_button.label = f"Next: 1/{len(card_embeds)}"
|
||||||
@ -2071,10 +2072,10 @@ async def open_choice_pack(
|
|||||||
)
|
)
|
||||||
if rarity_id >= 5:
|
if rarity_id >= 5:
|
||||||
tmp_msg = await pack_channel.send(
|
tmp_msg = await pack_channel.send(
|
||||||
content=f"<@&1163537676885033010> we've got an MVP!"
|
content="<@&1163537676885033010> we've got an MVP!"
|
||||||
)
|
)
|
||||||
else:
|
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:
|
while True:
|
||||||
await view.wait()
|
await view.wait()
|
||||||
@ -2089,7 +2090,7 @@ async def open_choice_pack(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"failed to create cards: {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(
|
await db_patch(
|
||||||
"packs",
|
"packs",
|
||||||
@ -2123,7 +2124,7 @@ async def open_choice_pack(
|
|||||||
|
|
||||||
view = Pagination([author], timeout=30)
|
view = Pagination([author], timeout=30)
|
||||||
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
|
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.cancel_button.style = discord.ButtonStyle.success
|
||||||
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
|
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
|
||||||
if page_num == 1:
|
if page_num == 1:
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import discord
|
import discord
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -54,6 +53,7 @@ COGS = [
|
|||||||
"cogs.players",
|
"cogs.players",
|
||||||
"cogs.gameplay",
|
"cogs.gameplay",
|
||||||
"cogs.economy_new.scouting",
|
"cogs.economy_new.scouting",
|
||||||
|
"cogs.refractor",
|
||||||
]
|
]
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
|
|||||||
261
tests/test_card_embed_refractor.py
Normal file
261
tests/test_card_embed_refractor.py
Normal 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]
|
||||||
201
tests/test_complete_game_hook.py
Normal file
201
tests/test_complete_game_hook.py
Normal 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
|
||||||
|
)
|
||||||
467
tests/test_refractor_commands.py
Normal file
467
tests/test_refractor_commands.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user