refactor: Consolidate scouting utilities, add test suite, use Discord timestamps
- Consolidate SCOUT_TOKENS_PER_DAY and get_scout_tokens_used() into helpers/scouting.py (was duplicated across 3 files) - Add midnight_timestamp() utility to helpers/utils.py - Remove _build_scouted_ids() wrapper, use self.claims directly - Fix build_scout_embed return type annotation - Use Discord <t:UNIX:R> relative timestamps for scout window countdown - Add 66-test suite covering helpers, ScoutView, and cog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d5bd86d52
commit
3c0fa133fd
@ -10,6 +10,7 @@ from discord import app_commands
|
|||||||
from discord.ext import commands, tasks
|
from discord.ext import commands, tasks
|
||||||
|
|
||||||
from api_calls import db_get
|
from api_calls import db_get
|
||||||
|
from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used
|
||||||
from helpers.utils import int_timestamp
|
from helpers.utils import int_timestamp
|
||||||
from helpers.discord_utils import get_team_embed
|
from helpers.discord_utils import get_team_embed
|
||||||
from helpers.main import get_team_by_owner
|
from helpers.main import get_team_by_owner
|
||||||
@ -17,8 +18,6 @@ from helpers.constants import PD_SEASON, IMAGES
|
|||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
SCOUT_TOKENS_PER_DAY = 2
|
|
||||||
|
|
||||||
|
|
||||||
class Scouting(commands.Cog):
|
class Scouting(commands.Cog):
|
||||||
"""Scout token tracking and expired opportunity cleanup."""
|
"""Scout token tracking and expired opportunity cleanup."""
|
||||||
@ -45,20 +44,7 @@ class Scouting(commands.Cog):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
now = datetime.datetime.now()
|
tokens_used = await get_scout_tokens_used(team["id"])
|
||||||
midnight = int_timestamp(
|
|
||||||
datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
used_today = await db_get(
|
|
||||||
"rewards",
|
|
||||||
params=[
|
|
||||||
("name", "Scout Token"),
|
|
||||||
("team_id", team["id"]),
|
|
||||||
("created_after", midnight),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
tokens_used = used_today["count"] if used_today else 0
|
|
||||||
tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used)
|
tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used)
|
||||||
|
|
||||||
embed = get_team_embed(title="Scout Tokens", team=team)
|
embed = get_team_embed(title="Scout Tokens", team=team)
|
||||||
|
|||||||
@ -17,7 +17,10 @@ from .selectors import (
|
|||||||
SelectView,
|
SelectView,
|
||||||
)
|
)
|
||||||
from .dropdowns import Dropdown, DropdownView
|
from .dropdowns import Dropdown, DropdownView
|
||||||
from .scout_view import ScoutView
|
|
||||||
|
# ScoutView intentionally NOT imported here to avoid circular import:
|
||||||
|
# helpers.main → discord_ui → scout_view → helpers.main
|
||||||
|
# Import directly: from discord_ui.scout_view import ScoutView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Question",
|
"Question",
|
||||||
@ -34,5 +37,4 @@ __all__ = [
|
|||||||
"SelectView",
|
"SelectView",
|
||||||
"Dropdown",
|
"Dropdown",
|
||||||
"DropdownView",
|
"DropdownView",
|
||||||
"ScoutView",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -3,32 +3,30 @@ Scout View — Face-down card button UI for the Scouting feature.
|
|||||||
|
|
||||||
When a player opens a pack, a ScoutView is posted with one button per card.
|
When a player opens a pack, a ScoutView is posted with one button per card.
|
||||||
Other players can click a button to "scout" (blind-pick) one card, receiving
|
Other players can click a button to "scout" (blind-pick) one card, receiving
|
||||||
a copy. The opener keeps all their cards. Multiple players can scout different
|
a copy. The opener keeps all their cards. Multiple players can scout the same
|
||||||
cards from the same pack — each costs one scout token.
|
card — each gets their own copy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from api_calls import db_get, db_post, db_patch
|
from api_calls import db_get, db_post
|
||||||
from helpers.main import get_team_by_owner, get_card_embeds
|
from helpers.main import get_team_by_owner, get_card_embeds
|
||||||
|
from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used
|
||||||
from helpers.utils import int_timestamp
|
from helpers.utils import int_timestamp
|
||||||
from helpers.discord_utils import get_team_embed
|
from helpers.discord_utils import get_team_embed
|
||||||
from helpers.constants import IMAGES, PD_SEASON
|
from helpers.constants import IMAGES, PD_SEASON
|
||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
SCOUT_TOKENS_PER_DAY = 2
|
|
||||||
|
|
||||||
|
|
||||||
class ScoutView(discord.ui.View):
|
class ScoutView(discord.ui.View):
|
||||||
"""Displays face-down card buttons for a scout opportunity.
|
"""Displays face-down card buttons for a scout opportunity.
|
||||||
|
|
||||||
- One button per card, labeled "Card 1" ... "Card N"
|
- One button per card, labeled "Card 1" ... "Card N"
|
||||||
- Any player EXCEPT the pack opener can interact
|
- Any player EXCEPT the pack opener can interact
|
||||||
- Each card can be scouted once; multiple players can scout different cards
|
- Any card can be scouted multiple times by different players
|
||||||
- One scout per player per pack
|
- One scout per player per pack
|
||||||
- Timeout: 30 minutes
|
- Timeout: 30 minutes
|
||||||
"""
|
"""
|
||||||
@ -40,6 +38,7 @@ class ScoutView(discord.ui.View):
|
|||||||
opener_team: dict,
|
opener_team: dict,
|
||||||
opener_user_id: int,
|
opener_user_id: int,
|
||||||
bot,
|
bot,
|
||||||
|
expires_unix: int = None,
|
||||||
):
|
):
|
||||||
super().__init__(timeout=1800.0)
|
super().__init__(timeout=1800.0)
|
||||||
self.scout_opp_id = scout_opp_id
|
self.scout_opp_id = scout_opp_id
|
||||||
@ -47,15 +46,18 @@ class ScoutView(discord.ui.View):
|
|||||||
self.opener_team = opener_team
|
self.opener_team = opener_team
|
||||||
self.opener_user_id = opener_user_id
|
self.opener_user_id = opener_user_id
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
self.expires_unix = expires_unix
|
||||||
self.message: discord.Message | None = None
|
self.message: discord.Message | None = None
|
||||||
self.card_lines: list[tuple[int, str]] = []
|
self.card_lines: list[tuple[int, str]] = []
|
||||||
|
|
||||||
# Per-card claim tracking: position -> scouter team name
|
# Per-card claim tracking: player_id -> list of scouter team names
|
||||||
self.claimed_positions: dict[int, str] = {}
|
self.claims: dict[int, list[str]] = {}
|
||||||
# Per-user lock: user IDs who have already scouted this pack
|
# Per-user lock: user IDs who have already scouted this pack
|
||||||
self.scouted_users: set[int] = set()
|
self.scouted_users: set[int] = set()
|
||||||
# Positions currently being processed (prevent double-click race)
|
# Users currently being processed (prevent double-click race)
|
||||||
self.processing: set[int] = set()
|
self.processing_users: set[int] = set()
|
||||||
|
# Total scout count
|
||||||
|
self.total_scouts = 0
|
||||||
|
|
||||||
for i, card in enumerate(cards):
|
for i, card in enumerate(cards):
|
||||||
button = ScoutButton(
|
button = ScoutButton(
|
||||||
@ -65,10 +67,6 @@ class ScoutView(discord.ui.View):
|
|||||||
)
|
)
|
||||||
self.add_item(button)
|
self.add_item(button)
|
||||||
|
|
||||||
@property
|
|
||||||
def all_claimed(self) -> bool:
|
|
||||||
return len(self.claimed_positions) >= len(self.cards)
|
|
||||||
|
|
||||||
async def update_message(self):
|
async def update_message(self):
|
||||||
"""Refresh the embed with current claim state."""
|
"""Refresh the embed with current claim state."""
|
||||||
if not self.message:
|
if not self.message:
|
||||||
@ -76,34 +74,26 @@ class ScoutView(discord.ui.View):
|
|||||||
|
|
||||||
from helpers.scouting import build_scouted_card_list
|
from helpers.scouting import build_scouted_card_list
|
||||||
|
|
||||||
scouted_ids = {}
|
card_list = build_scouted_card_list(self.card_lines, self.claims)
|
||||||
for pos, team_name in self.claimed_positions.items():
|
|
||||||
player_id = self.cards[pos]["player"]["player_id"]
|
|
||||||
scouted_ids[player_id] = team_name
|
|
||||||
|
|
||||||
card_list = build_scouted_card_list(self.card_lines, scouted_ids)
|
|
||||||
claim_count = len(self.claimed_positions)
|
|
||||||
|
|
||||||
if self.all_claimed:
|
|
||||||
title = "Fully Scouted!"
|
|
||||||
footer_text = f"Paper Dynasty Season {PD_SEASON} \u2022 All cards scouted"
|
|
||||||
else:
|
|
||||||
title = f"Scout Opportunity! ({claim_count}/{len(self.cards)} scouted)"
|
|
||||||
footer_text = (
|
|
||||||
f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
title = f"Scout Opportunity! ({self.total_scouts} scouted)"
|
||||||
embed = get_team_embed(title=title, team=self.opener_team)
|
embed = get_team_embed(title=title, team=self.opener_team)
|
||||||
embed.description = (
|
if self.expires_unix:
|
||||||
f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}\n\n"
|
time_line = f"Scout window closes <t:{self.expires_unix}:R>."
|
||||||
)
|
else:
|
||||||
if not self.all_claimed:
|
time_line = "Scout window closes in **30 minutes**."
|
||||||
embed.description += (
|
|
||||||
"Pick a card — but which is which?\n"
|
|
||||||
"Costs 1 Scout Token (2 per day, resets at midnight Central)."
|
|
||||||
)
|
|
||||||
|
|
||||||
embed.set_footer(text=footer_text, icon_url=IMAGES["logo"])
|
embed.description = (
|
||||||
|
f"**{self.opener_team['lname']}**'s pack\n\n"
|
||||||
|
f"{card_list}\n\n"
|
||||||
|
f"Pick a card — but which is which?\n"
|
||||||
|
f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n"
|
||||||
|
f"{time_line}"
|
||||||
|
)
|
||||||
|
embed.set_footer(
|
||||||
|
text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player",
|
||||||
|
icon_url=IMAGES["logo"],
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.message.edit(embed=embed, view=self)
|
await self.message.edit(embed=embed, view=self)
|
||||||
@ -119,18 +109,10 @@ class ScoutView(discord.ui.View):
|
|||||||
try:
|
try:
|
||||||
from helpers.scouting import build_scouted_card_list
|
from helpers.scouting import build_scouted_card_list
|
||||||
|
|
||||||
scouted_ids = {}
|
card_list = build_scouted_card_list(self.card_lines, self.claims)
|
||||||
for pos, team_name in self.claimed_positions.items():
|
|
||||||
player_id = self.cards[pos]["player"]["player_id"]
|
|
||||||
scouted_ids[player_id] = team_name
|
|
||||||
|
|
||||||
card_list = build_scouted_card_list(self.card_lines, scouted_ids)
|
if self.total_scouts > 0:
|
||||||
claim_count = len(self.claimed_positions)
|
title = f"Scout Window Closed ({self.total_scouts} scouted)"
|
||||||
|
|
||||||
if claim_count > 0:
|
|
||||||
title = (
|
|
||||||
f"Scout Window Closed ({claim_count}/{len(self.cards)} scouted)"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
title = "Scout Window Closed"
|
title = "Scout Window Closed"
|
||||||
|
|
||||||
@ -179,23 +161,11 @@ class ScoutButton(discord.ui.Button):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# This card already taken
|
# Prevent double-click race for same user
|
||||||
if self.position in view.claimed_positions:
|
if interaction.user.id in view.processing_users:
|
||||||
await interaction.response.send_message(
|
|
||||||
"This card was already scouted! Try a different one.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Prevent double-click race on same card
|
view.processing_users.add(interaction.user.id)
|
||||||
if self.position in view.processing:
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"Hold on, someone's claiming this card right now...",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
view.processing.add(self.position)
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -209,19 +179,7 @@ class ScoutButton(discord.ui.Button):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Check scout token balance
|
# Check scout token balance
|
||||||
now = datetime.datetime.now()
|
tokens_used = await get_scout_tokens_used(scouter_team["id"])
|
||||||
midnight = int_timestamp(
|
|
||||||
datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
|
|
||||||
)
|
|
||||||
used_today = await db_get(
|
|
||||||
"rewards",
|
|
||||||
params=[
|
|
||||||
("name", "Scout Token"),
|
|
||||||
("team_id", scouter_team["id"]),
|
|
||||||
("created_after", midnight),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
tokens_used = used_today["count"] if used_today else 0
|
|
||||||
|
|
||||||
if tokens_used >= SCOUT_TOKENS_PER_DAY:
|
if tokens_used >= SCOUT_TOKENS_PER_DAY:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
@ -257,7 +215,7 @@ class ScoutButton(discord.ui.Button):
|
|||||||
"team_id": scouter_team["id"],
|
"team_id": scouter_team["id"],
|
||||||
"season": current["season"] if current else PD_SEASON,
|
"season": current["season"] if current else PD_SEASON,
|
||||||
"week": current["week"] if current else 1,
|
"week": current["week"] if current else 1,
|
||||||
"created": int_timestamp(now),
|
"created": int_timestamp(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -275,19 +233,12 @@ class ScoutButton(discord.ui.Button):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Track the claim
|
# Track the claim
|
||||||
view.claimed_positions[self.position] = scouter_team["lname"]
|
player_id = self.card["player"]["player_id"]
|
||||||
|
if player_id not in view.claims:
|
||||||
|
view.claims[player_id] = []
|
||||||
|
view.claims[player_id].append(scouter_team["lname"])
|
||||||
view.scouted_users.add(interaction.user.id)
|
view.scouted_users.add(interaction.user.id)
|
||||||
|
view.total_scouts += 1
|
||||||
# Update this button
|
|
||||||
self.disabled = True
|
|
||||||
self.style = discord.ButtonStyle.success
|
|
||||||
self.label = "Scouted!"
|
|
||||||
|
|
||||||
# If all cards claimed, disable remaining buttons and stop
|
|
||||||
if view.all_claimed:
|
|
||||||
for item in view.children:
|
|
||||||
item.disabled = True
|
|
||||||
view.stop()
|
|
||||||
|
|
||||||
# Update the shared embed
|
# Update the shared embed
|
||||||
await view.update_message()
|
await view.update_message()
|
||||||
@ -336,4 +287,4 @@ class ScoutButton(discord.ui.Button):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
view.processing.discard(self.position)
|
view.processing_users.discard(interaction.user.id)
|
||||||
|
|||||||
@ -12,13 +12,14 @@ import random
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from api_calls import db_post
|
from api_calls import db_get, db_post
|
||||||
from helpers.utils import int_timestamp
|
from helpers.utils import int_timestamp, midnight_timestamp
|
||||||
from helpers.discord_utils import get_team_embed
|
from helpers.discord_utils import get_team_embed
|
||||||
from helpers.constants import IMAGES, PD_SEASON
|
from helpers.constants import IMAGES, PD_SEASON
|
||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
|
SCOUT_TOKENS_PER_DAY = 2
|
||||||
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
|
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
|
||||||
|
|
||||||
# Rarity value → display symbol
|
# Rarity value → display symbol
|
||||||
@ -32,6 +33,19 @@ RARITY_SYMBOLS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_scout_tokens_used(team_id: int) -> int:
|
||||||
|
"""Return how many scout tokens a team has used today."""
|
||||||
|
used_today = await db_get(
|
||||||
|
"rewards",
|
||||||
|
params=[
|
||||||
|
("name", "Scout Token"),
|
||||||
|
("team_id", team_id),
|
||||||
|
("created_after", midnight_timestamp()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return used_today["count"] if used_today else 0
|
||||||
|
|
||||||
|
|
||||||
def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]:
|
def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]:
|
||||||
"""Build a shuffled list of (player_id, display_line) tuples."""
|
"""Build a shuffled list of (player_id, display_line) tuples."""
|
||||||
lines = []
|
lines = []
|
||||||
@ -53,7 +67,8 @@ def build_scout_embed(
|
|||||||
opener_team: dict,
|
opener_team: dict,
|
||||||
cards: list[dict],
|
cards: list[dict],
|
||||||
card_lines: list[tuple[int, str]] = None,
|
card_lines: list[tuple[int, str]] = None,
|
||||||
) -> discord.Embed:
|
expires_unix: int = None,
|
||||||
|
) -> tuple[discord.Embed, list[tuple[int, str]]]:
|
||||||
"""Build the embed shown above the scout buttons.
|
"""Build the embed shown above the scout buttons.
|
||||||
|
|
||||||
Shows a shuffled list of cards (rarity + player name) so scouters
|
Shows a shuffled list of cards (rarity + player name) so scouters
|
||||||
@ -67,12 +82,17 @@ def build_scout_embed(
|
|||||||
|
|
||||||
card_list = "\n".join(line for _, line in card_lines)
|
card_list = "\n".join(line for _, line in card_lines)
|
||||||
|
|
||||||
|
if expires_unix:
|
||||||
|
time_line = f"Scout window closes <t:{expires_unix}:R>."
|
||||||
|
else:
|
||||||
|
time_line = "Scout window closes in **30 minutes**."
|
||||||
|
|
||||||
embed.description = (
|
embed.description = (
|
||||||
f"**{opener_team['lname']}** just opened a pack!\n\n"
|
f"**{opener_team['lname']}** just opened a pack!\n\n"
|
||||||
f"**Cards in this pack:**\n{card_list}\n\n"
|
f"**Cards in this pack:**\n{card_list}\n\n"
|
||||||
f"Pick a card — but which is which?\n"
|
f"Pick a card — but which is which?\n"
|
||||||
f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n"
|
f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n"
|
||||||
f"This window closes in **30 minutes**."
|
f"{time_line}"
|
||||||
)
|
)
|
||||||
embed.set_footer(
|
embed.set_footer(
|
||||||
text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack",
|
text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack",
|
||||||
@ -83,20 +103,25 @@ def build_scout_embed(
|
|||||||
|
|
||||||
def build_scouted_card_list(
|
def build_scouted_card_list(
|
||||||
card_lines: list[tuple[int, str]],
|
card_lines: list[tuple[int, str]],
|
||||||
scouted_cards: dict[int, str],
|
scouted_cards: dict[int, list[str]],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Rebuild the card list marking scouted cards with the scouter's team name.
|
"""Rebuild the card list marking scouted cards with scouter team names.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
card_lines : shuffled list of (player_id, display_line) tuples
|
card_lines : shuffled list of (player_id, display_line) tuples
|
||||||
scouted_cards : {player_id: scouter_team_name} for each claimed card
|
scouted_cards : {player_id: [team_name, ...]} for each claimed card
|
||||||
"""
|
"""
|
||||||
result = []
|
result = []
|
||||||
for player_id, line in card_lines:
|
for player_id, line in card_lines:
|
||||||
if player_id in scouted_cards:
|
teams = scouted_cards.get(player_id)
|
||||||
team_name = scouted_cards[player_id]
|
if teams:
|
||||||
result.append(f"{line} \u2014 \u2714\ufe0f *{team_name}*")
|
count = len(teams)
|
||||||
|
names = ", ".join(f"*{t}*" for t in teams)
|
||||||
|
if count == 1:
|
||||||
|
result.append(f"{line} \u2014 \u2714\ufe0f {names}")
|
||||||
|
else:
|
||||||
|
result.append(f"{line} \u2014 \u2714\ufe0f x{count} ({names})")
|
||||||
else:
|
else:
|
||||||
result.append(line)
|
result.append(line)
|
||||||
return "\n".join(result)
|
return "\n".join(result)
|
||||||
@ -152,7 +177,12 @@ async def create_scout_opportunity(
|
|||||||
logger.error(f"Failed to create scout opportunity: {e}")
|
logger.error(f"Failed to create scout opportunity: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
embed, card_lines = build_scout_embed(opener_team, pack_cards)
|
expires_unix = int(
|
||||||
|
(now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)).timestamp()
|
||||||
|
)
|
||||||
|
embed, card_lines = build_scout_embed(
|
||||||
|
opener_team, pack_cards, expires_unix=expires_unix
|
||||||
|
)
|
||||||
|
|
||||||
# Get bot reference from context
|
# Get bot reference from context
|
||||||
bot = getattr(context, "bot", None) or getattr(context, "client", None)
|
bot = getattr(context, "bot", None) or getattr(context, "client", None)
|
||||||
@ -163,6 +193,7 @@ async def create_scout_opportunity(
|
|||||||
opener_team=opener_team,
|
opener_team=opener_team,
|
||||||
opener_user_id=opener_user.id,
|
opener_user_id=opener_user.id,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
|
expires_unix=expires_unix,
|
||||||
)
|
)
|
||||||
view.card_lines = card_lines
|
view.card_lines = card_lines
|
||||||
|
|
||||||
|
|||||||
115
helpers/utils.py
115
helpers/utils.py
@ -4,6 +4,7 @@ General Utilities
|
|||||||
This module contains standalone utility functions with minimal dependencies,
|
This module contains standalone utility functions with minimal dependencies,
|
||||||
including timestamp conversion, position abbreviations, and simple helpers.
|
including timestamp conversion, position abbreviations, and simple helpers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import discord
|
import discord
|
||||||
@ -16,48 +17,55 @@ def int_timestamp(datetime_obj: Optional[datetime.datetime] = None):
|
|||||||
return int(datetime.datetime.now().timestamp())
|
return int(datetime.datetime.now().timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def midnight_timestamp() -> int:
|
||||||
|
"""Return today's midnight (00:00:00) as an integer millisecond timestamp."""
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
midnight = datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
|
||||||
|
return int_timestamp(midnight)
|
||||||
|
|
||||||
|
|
||||||
def get_pos_abbrev(field_pos: str) -> str:
|
def get_pos_abbrev(field_pos: str) -> str:
|
||||||
"""Convert position name to standard abbreviation."""
|
"""Convert position name to standard abbreviation."""
|
||||||
if field_pos.lower() == 'catcher':
|
if field_pos.lower() == "catcher":
|
||||||
return 'C'
|
return "C"
|
||||||
elif field_pos.lower() == 'first baseman':
|
elif field_pos.lower() == "first baseman":
|
||||||
return '1B'
|
return "1B"
|
||||||
elif field_pos.lower() == 'second baseman':
|
elif field_pos.lower() == "second baseman":
|
||||||
return '2B'
|
return "2B"
|
||||||
elif field_pos.lower() == 'third baseman':
|
elif field_pos.lower() == "third baseman":
|
||||||
return '3B'
|
return "3B"
|
||||||
elif field_pos.lower() == 'shortstop':
|
elif field_pos.lower() == "shortstop":
|
||||||
return 'SS'
|
return "SS"
|
||||||
elif field_pos.lower() == 'left fielder':
|
elif field_pos.lower() == "left fielder":
|
||||||
return 'LF'
|
return "LF"
|
||||||
elif field_pos.lower() == 'center fielder':
|
elif field_pos.lower() == "center fielder":
|
||||||
return 'CF'
|
return "CF"
|
||||||
elif field_pos.lower() == 'right fielder':
|
elif field_pos.lower() == "right fielder":
|
||||||
return 'RF'
|
return "RF"
|
||||||
else:
|
else:
|
||||||
return 'P'
|
return "P"
|
||||||
|
|
||||||
|
|
||||||
def position_name_to_abbrev(position_name):
|
def position_name_to_abbrev(position_name):
|
||||||
"""Convert position name to abbreviation (alternate format)."""
|
"""Convert position name to abbreviation (alternate format)."""
|
||||||
if position_name == 'Catcher':
|
if position_name == "Catcher":
|
||||||
return 'C'
|
return "C"
|
||||||
elif position_name == 'First Base':
|
elif position_name == "First Base":
|
||||||
return '1B'
|
return "1B"
|
||||||
elif position_name == 'Second Base':
|
elif position_name == "Second Base":
|
||||||
return '2B'
|
return "2B"
|
||||||
elif position_name == 'Third Base':
|
elif position_name == "Third Base":
|
||||||
return '3B'
|
return "3B"
|
||||||
elif position_name == 'Shortstop':
|
elif position_name == "Shortstop":
|
||||||
return 'SS'
|
return "SS"
|
||||||
elif position_name == 'Left Field':
|
elif position_name == "Left Field":
|
||||||
return 'LF'
|
return "LF"
|
||||||
elif position_name == 'Center Field':
|
elif position_name == "Center Field":
|
||||||
return 'CF'
|
return "CF"
|
||||||
elif position_name == 'Right Field':
|
elif position_name == "Right Field":
|
||||||
return 'RF'
|
return "RF"
|
||||||
elif position_name == 'Pitcher':
|
elif position_name == "Pitcher":
|
||||||
return 'P'
|
return "P"
|
||||||
else:
|
else:
|
||||||
return position_name
|
return position_name
|
||||||
|
|
||||||
@ -67,13 +75,13 @@ def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool:
|
|||||||
for x in user.roles:
|
for x in user.roles:
|
||||||
if x.name == role_name:
|
if x.name == role_name:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_roster_sheet_legacy(team):
|
def get_roster_sheet_legacy(team):
|
||||||
"""Get legacy roster sheet URL for a team."""
|
"""Get legacy roster sheet URL for a team."""
|
||||||
return f'https://docs.google.com/spreadsheets/d/{team.gsheet}/edit'
|
return f"https://docs.google.com/spreadsheets/d/{team.gsheet}/edit"
|
||||||
|
|
||||||
|
|
||||||
def get_roster_sheet(team):
|
def get_roster_sheet(team):
|
||||||
@ -83,13 +91,15 @@ def get_roster_sheet(team):
|
|||||||
Handles both dict and Team object formats.
|
Handles both dict and Team object formats.
|
||||||
"""
|
"""
|
||||||
# Handle both dict (team["gsheet"]) and object (team.gsheet) formats
|
# Handle both dict (team["gsheet"]) and object (team.gsheet) formats
|
||||||
gsheet = team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None)
|
gsheet = (
|
||||||
return f'https://docs.google.com/spreadsheets/d/{gsheet}/edit'
|
team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None)
|
||||||
|
)
|
||||||
|
return f"https://docs.google.com/spreadsheets/d/{gsheet}/edit"
|
||||||
|
|
||||||
|
|
||||||
def get_player_url(team, player) -> str:
|
def get_player_url(team, player) -> str:
|
||||||
"""Generate player URL for SBA or Baseball Reference."""
|
"""Generate player URL for SBA or Baseball Reference."""
|
||||||
if team.get('league') == 'SBA':
|
if team.get("league") == "SBA":
|
||||||
return f'https://statsplus.net/super-baseball-association/player/{player["player_id"]}'
|
return f'https://statsplus.net/super-baseball-association/player/{player["player_id"]}'
|
||||||
else:
|
else:
|
||||||
return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml'
|
return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml'
|
||||||
@ -101,7 +111,7 @@ def owner_only(ctx) -> bool:
|
|||||||
owners = [287463767924137994, 1087936030899347516]
|
owners = [287463767924137994, 1087936030899347516]
|
||||||
|
|
||||||
# Handle both Context (has .author) and Interaction (has .user) objects
|
# Handle both Context (has .author) and Interaction (has .user) objects
|
||||||
user = getattr(ctx, 'user', None) or getattr(ctx, 'author', None)
|
user = getattr(ctx, "user", None) or getattr(ctx, "author", None)
|
||||||
|
|
||||||
if user and user.id in owners:
|
if user and user.id in owners:
|
||||||
return True
|
return True
|
||||||
@ -121,35 +131,36 @@ def get_context_user(ctx):
|
|||||||
discord.User or discord.Member: The user who invoked the command
|
discord.User or discord.Member: The user who invoked the command
|
||||||
"""
|
"""
|
||||||
# Handle both Context (has .author) and Interaction (has .user) objects
|
# Handle both Context (has .author) and Interaction (has .user) objects
|
||||||
return getattr(ctx, 'user', None) or getattr(ctx, 'author', None)
|
return getattr(ctx, "user", None) or getattr(ctx, "author", None)
|
||||||
|
|
||||||
|
|
||||||
def get_cal_user(ctx):
|
def get_cal_user(ctx):
|
||||||
"""Get the Cal user from context. Always returns an object with .mention attribute."""
|
"""Get the Cal user from context. Always returns an object with .mention attribute."""
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('discord_app')
|
|
||||||
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
# Define placeholder user class first
|
# Define placeholder user class first
|
||||||
class PlaceholderUser:
|
class PlaceholderUser:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.mention = "<@287463767924137994>"
|
self.mention = "<@287463767924137994>"
|
||||||
self.id = 287463767924137994
|
self.id = 287463767924137994
|
||||||
|
|
||||||
# Handle both Context and Interaction objects
|
# Handle both Context and Interaction objects
|
||||||
if hasattr(ctx, 'bot'): # Context object
|
if hasattr(ctx, "bot"): # Context object
|
||||||
bot = ctx.bot
|
bot = ctx.bot
|
||||||
logger.debug("get_cal_user: Using Context object")
|
logger.debug("get_cal_user: Using Context object")
|
||||||
elif hasattr(ctx, 'client'): # Interaction object
|
elif hasattr(ctx, "client"): # Interaction object
|
||||||
bot = ctx.client
|
bot = ctx.client
|
||||||
logger.debug("get_cal_user: Using Interaction object")
|
logger.debug("get_cal_user: Using Interaction object")
|
||||||
else:
|
else:
|
||||||
logger.error("get_cal_user: No bot or client found in context")
|
logger.error("get_cal_user: No bot or client found in context")
|
||||||
return PlaceholderUser()
|
return PlaceholderUser()
|
||||||
|
|
||||||
if not bot:
|
if not bot:
|
||||||
logger.error("get_cal_user: bot is None")
|
logger.error("get_cal_user: bot is None")
|
||||||
return PlaceholderUser()
|
return PlaceholderUser()
|
||||||
|
|
||||||
logger.debug(f"get_cal_user: Searching among members")
|
logger.debug(f"get_cal_user: Searching among members")
|
||||||
try:
|
try:
|
||||||
for user in bot.get_all_members():
|
for user in bot.get_all_members():
|
||||||
@ -158,7 +169,7 @@ def get_cal_user(ctx):
|
|||||||
return user
|
return user
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"get_cal_user: Exception in get_all_members: {e}")
|
logger.error(f"get_cal_user: Exception in get_all_members: {e}")
|
||||||
|
|
||||||
# Fallback: try to get user directly by ID
|
# Fallback: try to get user directly by ID
|
||||||
logger.debug("get_cal_user: User not found in get_all_members, trying get_user")
|
logger.debug("get_cal_user: User not found in get_all_members, trying get_user")
|
||||||
try:
|
try:
|
||||||
@ -170,7 +181,7 @@ def get_cal_user(ctx):
|
|||||||
logger.debug("get_cal_user: get_user returned None")
|
logger.debug("get_cal_user: get_user returned None")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"get_cal_user: Exception in get_user: {e}")
|
logger.error(f"get_cal_user: Exception in get_user: {e}")
|
||||||
|
|
||||||
# Last resort: return a placeholder user object with mention
|
# Last resort: return a placeholder user object with mention
|
||||||
logger.debug("get_cal_user: Using placeholder user")
|
logger.debug("get_cal_user: Using placeholder user")
|
||||||
return PlaceholderUser()
|
return PlaceholderUser()
|
||||||
|
|||||||
0
tests/scouting/__init__.py
Normal file
0
tests/scouting/__init__.py
Normal file
160
tests/scouting/conftest.py
Normal file
160
tests/scouting/conftest.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""Shared fixtures for scouting feature tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample data factories
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(player_id, name, rarity_name, rarity_value, headshot=None):
|
||||||
|
"""Build a minimal player dict matching the API shape used by scouting."""
|
||||||
|
return {
|
||||||
|
"player_id": player_id,
|
||||||
|
"p_name": name,
|
||||||
|
"rarity": {"name": rarity_name, "value": rarity_value, "color": "ffffff"},
|
||||||
|
"headshot": headshot or "https://example.com/headshot.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_card(card_id, player):
|
||||||
|
"""Wrap a player dict inside a card dict (as returned by the cards API)."""
|
||||||
|
return {"id": card_id, "player": player}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_players():
|
||||||
|
"""Five players spanning different rarities for a realistic pack."""
|
||||||
|
return [
|
||||||
|
_make_player(101, "Mike Trout", "MVP", 5),
|
||||||
|
_make_player(102, "Juan Soto", "All-Star", 3),
|
||||||
|
_make_player(103, "Marcus Semien", "Starter", 2),
|
||||||
|
_make_player(104, "Willy Adames", "Reserve", 1),
|
||||||
|
_make_player(105, "Generic Bench", "Replacement", 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_cards(sample_players):
|
||||||
|
"""Five card dicts wrapping the sample players."""
|
||||||
|
return [_make_card(i + 1, p) for i, p in enumerate(sample_players)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def opener_team():
|
||||||
|
"""Team dict for the pack opener."""
|
||||||
|
return {
|
||||||
|
"id": 10,
|
||||||
|
"abbrev": "OPN",
|
||||||
|
"sname": "Openers",
|
||||||
|
"lname": "Opening Squad",
|
||||||
|
"gm_id": 99999,
|
||||||
|
"gmname": "Opener GM",
|
||||||
|
"color": "a6ce39",
|
||||||
|
"logo": "https://example.com/logo.png",
|
||||||
|
"season": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scouter_team():
|
||||||
|
"""Team dict for a player who scouts a card."""
|
||||||
|
return {
|
||||||
|
"id": 20,
|
||||||
|
"abbrev": "SCT",
|
||||||
|
"sname": "Scouts",
|
||||||
|
"lname": "Scouting Squad",
|
||||||
|
"gm_id": 88888,
|
||||||
|
"gmname": "Scout GM",
|
||||||
|
"color": "3498db",
|
||||||
|
"logo": "https://example.com/scout_logo.png",
|
||||||
|
"season": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scouter_team_2():
|
||||||
|
"""Second scouter team for multi-scout tests."""
|
||||||
|
return {
|
||||||
|
"id": 30,
|
||||||
|
"abbrev": "SC2",
|
||||||
|
"sname": "Scouts2",
|
||||||
|
"lname": "Second Scouts",
|
||||||
|
"gm_id": 77777,
|
||||||
|
"gmname": "Scout GM 2",
|
||||||
|
"color": "e74c3c",
|
||||||
|
"logo": "https://example.com/scout2_logo.png",
|
||||||
|
"season": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discord mocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bot():
|
||||||
|
"""Mock Discord bot."""
|
||||||
|
bot = AsyncMock(spec=commands.Bot)
|
||||||
|
bot.get_cog = Mock(return_value=None)
|
||||||
|
bot.add_cog = AsyncMock()
|
||||||
|
bot.wait_until_ready = AsyncMock()
|
||||||
|
|
||||||
|
# Mock guild / channel lookup for send_to_channel
|
||||||
|
channel_mock = AsyncMock(spec=discord.TextChannel)
|
||||||
|
channel_mock.send = AsyncMock()
|
||||||
|
guild_mock = Mock(spec=discord.Guild)
|
||||||
|
guild_mock.text_channels = [channel_mock]
|
||||||
|
channel_mock.name = "pd-network-news"
|
||||||
|
bot.guilds = [guild_mock]
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interaction():
|
||||||
|
"""Mock Discord interaction for slash commands."""
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.response.send_message = AsyncMock()
|
||||||
|
interaction.response.is_done = Mock(return_value=False)
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
|
||||||
|
interaction.user = Mock(spec=discord.Member)
|
||||||
|
interaction.user.id = 12345
|
||||||
|
interaction.user.mention = "<@12345>"
|
||||||
|
|
||||||
|
interaction.channel = Mock(spec=discord.TextChannel)
|
||||||
|
interaction.channel.name = "pack-openings"
|
||||||
|
interaction.channel.send = AsyncMock()
|
||||||
|
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_channel():
|
||||||
|
"""Mock #pack-openings channel."""
|
||||||
|
channel = AsyncMock(spec=discord.TextChannel)
|
||||||
|
channel.name = "pack-openings"
|
||||||
|
channel.send = AsyncMock()
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging suppression
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def silence_logging():
|
||||||
|
"""Suppress log noise during tests."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger("discord_app").setLevel(logging.CRITICAL)
|
||||||
1029
tests/scouting/test_scout_view.py
Normal file
1029
tests/scouting/test_scout_view.py
Normal file
File diff suppressed because it is too large
Load Diff
269
tests/scouting/test_scouting_cog.py
Normal file
269
tests/scouting/test_scouting_cog.py
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
"""Tests for cogs/economy_new/scouting.py — the Scouting cog.
|
||||||
|
|
||||||
|
Covers the /scout-tokens command and the cleanup_expired background task.
|
||||||
|
|
||||||
|
Note: Scouting.__init__ calls self.cleanup_expired.start() which requires
|
||||||
|
a running event loop. All tests that instantiate the cog must be async.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from cogs.economy_new.scouting import Scouting, SCOUT_TOKENS_PER_DAY
|
||||||
|
|
||||||
|
|
||||||
|
def _make_team():
|
||||||
|
return {
|
||||||
|
"id": 1,
|
||||||
|
"lname": "Test Team",
|
||||||
|
"color": "a6ce39",
|
||||||
|
"logo": "https://example.com/logo.png",
|
||||||
|
"season": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cog setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoutingCogSetup:
|
||||||
|
"""Tests for cog initialization and lifecycle."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cog_initializes(self, mock_bot):
|
||||||
|
"""The Scouting cog should initialize without errors."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
assert cog.bot is mock_bot
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cleanup_task_starts(self, mock_bot):
|
||||||
|
"""The cleanup_expired loop task should be started on init."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
assert cog.cleanup_expired.is_running()
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cog_unload_calls_cancel(self, mock_bot):
|
||||||
|
"""Unloading the cog should call cancel on the cleanup task."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
# Verify cog_unload runs without error
|
||||||
|
await cog.cog_unload()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /scout-tokens command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoutTokensCommand:
|
||||||
|
"""Tests for the /scout-tokens slash command."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
|
||||||
|
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
|
||||||
|
async def test_shows_remaining_tokens(
|
||||||
|
self, mock_get_team, mock_get_tokens, mock_bot
|
||||||
|
):
|
||||||
|
"""Should display the correct number of remaining tokens."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_get_team.return_value = _make_team()
|
||||||
|
mock_get_tokens.return_value = 1 # 1 used today
|
||||||
|
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
|
||||||
|
await cog.scout_tokens_command.callback(cog, interaction)
|
||||||
|
|
||||||
|
interaction.response.defer.assert_called_once_with(ephemeral=True)
|
||||||
|
interaction.followup.send.assert_called_once()
|
||||||
|
|
||||||
|
call_kwargs = interaction.followup.send.call_args[1]
|
||||||
|
embed = call_kwargs["embed"]
|
||||||
|
remaining = SCOUT_TOKENS_PER_DAY - 1
|
||||||
|
assert str(remaining) in embed.description
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
|
||||||
|
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
|
||||||
|
async def test_no_team_rejects(self, mock_get_team, mock_get_tokens, mock_bot):
|
||||||
|
"""A user without a PD team should get a rejection message."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_get_team.return_value = None
|
||||||
|
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
|
||||||
|
await cog.scout_tokens_command.callback(cog, interaction)
|
||||||
|
|
||||||
|
msg = interaction.followup.send.call_args[0][0]
|
||||||
|
assert "team" in msg.lower()
|
||||||
|
mock_get_tokens.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
|
||||||
|
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
|
||||||
|
async def test_all_tokens_used_shows_zero(
|
||||||
|
self, mock_get_team, mock_get_tokens, mock_bot
|
||||||
|
):
|
||||||
|
"""When all tokens are used, should show 0 remaining with extra message."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_get_team.return_value = _make_team()
|
||||||
|
mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY
|
||||||
|
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
|
||||||
|
await cog.scout_tokens_command.callback(cog, interaction)
|
||||||
|
|
||||||
|
embed = interaction.followup.send.call_args[1]["embed"]
|
||||||
|
assert "0" in embed.description
|
||||||
|
assert (
|
||||||
|
"used all" in embed.description.lower()
|
||||||
|
or "tomorrow" in embed.description.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
|
||||||
|
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
|
||||||
|
async def test_no_tokens_used_shows_full(
|
||||||
|
self, mock_get_team, mock_get_tokens, mock_bot
|
||||||
|
):
|
||||||
|
"""When no tokens have been used, should show the full daily allowance."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_get_team.return_value = _make_team()
|
||||||
|
mock_get_tokens.return_value = 0
|
||||||
|
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
|
||||||
|
await cog.scout_tokens_command.callback(cog, interaction)
|
||||||
|
|
||||||
|
embed = interaction.followup.send.call_args[1]["embed"]
|
||||||
|
assert str(SCOUT_TOKENS_PER_DAY) in embed.description
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
|
||||||
|
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
|
||||||
|
async def test_db_get_returns_none(self, mock_get_team, mock_get_tokens, mock_bot):
|
||||||
|
"""If get_scout_tokens_used returns 0 (API failure handled internally), should show full tokens."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_get_team.return_value = _make_team()
|
||||||
|
mock_get_tokens.return_value = (
|
||||||
|
0 # get_scout_tokens_used handles None internally
|
||||||
|
)
|
||||||
|
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
|
||||||
|
await cog.scout_tokens_command.callback(cog, interaction)
|
||||||
|
|
||||||
|
embed = interaction.followup.send.call_args[1]["embed"]
|
||||||
|
assert str(SCOUT_TOKENS_PER_DAY) in embed.description
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
|
||||||
|
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
|
||||||
|
async def test_over_limit_tokens_shows_zero(
|
||||||
|
self, mock_get_team, mock_get_tokens, mock_bot
|
||||||
|
):
|
||||||
|
"""If somehow more tokens than the daily limit were used, should show 0 not negative."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_get_team.return_value = _make_team()
|
||||||
|
mock_get_tokens.return_value = 5 # more than SCOUT_TOKENS_PER_DAY
|
||||||
|
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.followup = AsyncMock()
|
||||||
|
interaction.followup.send = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
|
||||||
|
await cog.scout_tokens_command.callback(cog, interaction)
|
||||||
|
|
||||||
|
embed = interaction.followup.send.call_args[1]["embed"]
|
||||||
|
# Should show "0" not "-3"
|
||||||
|
assert "0" in embed.description
|
||||||
|
assert "-" not in embed.description.split("remaining")[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cleanup_expired task
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupExpired:
|
||||||
|
"""Tests for the background cleanup task."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock)
|
||||||
|
async def test_cleanup_logs_expired_opportunities(self, mock_db_get, mock_bot):
|
||||||
|
"""The cleanup task should query for expired unclaimed opportunities."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_db_get.return_value = {"count": 3}
|
||||||
|
|
||||||
|
# Call the coroutine directly (not via the loop)
|
||||||
|
await cog.cleanup_expired.coro(cog)
|
||||||
|
|
||||||
|
mock_db_get.assert_called_once()
|
||||||
|
call_args = mock_db_get.call_args
|
||||||
|
assert call_args[0][0] == "scout_opportunities"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock)
|
||||||
|
async def test_cleanup_handles_api_failure(self, mock_db_get, mock_bot):
|
||||||
|
"""Cleanup should not crash if the API is unavailable."""
|
||||||
|
cog = Scouting(mock_bot)
|
||||||
|
cog.cleanup_expired.cancel()
|
||||||
|
|
||||||
|
mock_db_get.side_effect = Exception("API not ready")
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
await cog.cleanup_expired.coro(cog)
|
||||||
374
tests/scouting/test_scouting_helpers.py
Normal file
374
tests/scouting/test_scouting_helpers.py
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
"""Tests for helpers/scouting.py — embed builders and scout opportunity creation.
|
||||||
|
|
||||||
|
Covers the pure functions (_build_card_lines, build_scout_embed,
|
||||||
|
build_scouted_card_list) and the async create_scout_opportunity flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from helpers.scouting import (
|
||||||
|
_build_card_lines,
|
||||||
|
build_scout_embed,
|
||||||
|
build_scouted_card_list,
|
||||||
|
create_scout_opportunity,
|
||||||
|
RARITY_SYMBOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_card_lines
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildCardLines:
|
||||||
|
"""Tests for the shuffled card line builder."""
|
||||||
|
|
||||||
|
def test_returns_correct_count(self, sample_cards):
|
||||||
|
"""Should produce one line per card in the pack."""
|
||||||
|
lines = _build_card_lines(sample_cards)
|
||||||
|
assert len(lines) == len(sample_cards)
|
||||||
|
|
||||||
|
def test_each_line_contains_player_id(self, sample_cards):
|
||||||
|
"""Each tuple's first element should be the player_id from the card."""
|
||||||
|
lines = _build_card_lines(sample_cards)
|
||||||
|
ids = {pid for pid, _ in lines}
|
||||||
|
expected_ids = {c["player"]["player_id"] for c in sample_cards}
|
||||||
|
assert ids == expected_ids
|
||||||
|
|
||||||
|
def test_each_line_contains_player_name(self, sample_cards):
|
||||||
|
"""The display string should include the player's name."""
|
||||||
|
lines = _build_card_lines(sample_cards)
|
||||||
|
for pid, display in lines:
|
||||||
|
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
|
||||||
|
assert card["player"]["p_name"] in display
|
||||||
|
|
||||||
|
def test_each_line_contains_rarity_name(self, sample_cards):
|
||||||
|
"""The display string should include the rarity tier name."""
|
||||||
|
lines = _build_card_lines(sample_cards)
|
||||||
|
for pid, display in lines:
|
||||||
|
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
|
||||||
|
assert card["player"]["rarity"]["name"] in display
|
||||||
|
|
||||||
|
def test_rarity_symbol_present(self, sample_cards):
|
||||||
|
"""Each line should start with the appropriate rarity emoji."""
|
||||||
|
lines = _build_card_lines(sample_cards)
|
||||||
|
for pid, display in lines:
|
||||||
|
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
|
||||||
|
rarity_val = card["player"]["rarity"]["value"]
|
||||||
|
expected_symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab")
|
||||||
|
assert display.startswith(expected_symbol)
|
||||||
|
|
||||||
|
def test_output_is_shuffled(self, sample_cards):
|
||||||
|
"""Over many runs, the order should not always match the input order.
|
||||||
|
|
||||||
|
We run 20 iterations — if it comes out sorted every time, the shuffle
|
||||||
|
is broken (probability ~1/20! per run, effectively zero).
|
||||||
|
"""
|
||||||
|
input_order = [c["player"]["player_id"] for c in sample_cards]
|
||||||
|
saw_different = False
|
||||||
|
for _ in range(20):
|
||||||
|
lines = _build_card_lines(sample_cards)
|
||||||
|
output_order = [pid for pid, _ in lines]
|
||||||
|
if output_order != input_order:
|
||||||
|
saw_different = True
|
||||||
|
break
|
||||||
|
assert saw_different, "Card lines were never shuffled across 20 runs"
|
||||||
|
|
||||||
|
def test_empty_cards(self):
|
||||||
|
"""Empty input should produce an empty list."""
|
||||||
|
assert _build_card_lines([]) == []
|
||||||
|
|
||||||
|
def test_unknown_rarity_uses_fallback_symbol(self):
|
||||||
|
"""A rarity value not in RARITY_SYMBOLS should get the black circle fallback."""
|
||||||
|
card = {
|
||||||
|
"id": 99,
|
||||||
|
"player": {
|
||||||
|
"player_id": 999,
|
||||||
|
"p_name": "Unknown Rarity",
|
||||||
|
"rarity": {"name": "Legendary", "value": 99, "color": "gold"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lines = _build_card_lines([card])
|
||||||
|
assert lines[0][1].startswith("\u26ab") # black circle fallback
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_scout_embed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildScoutEmbed:
|
||||||
|
"""Tests for the embed builder shown above scout buttons."""
|
||||||
|
|
||||||
|
def test_returns_embed_and_card_lines(self, opener_team, sample_cards):
|
||||||
|
"""Should return a (discord.Embed, list) tuple."""
|
||||||
|
embed, card_lines = build_scout_embed(opener_team, sample_cards)
|
||||||
|
assert isinstance(embed, discord.Embed)
|
||||||
|
assert isinstance(card_lines, list)
|
||||||
|
assert len(card_lines) == len(sample_cards)
|
||||||
|
|
||||||
|
def test_embed_description_contains_team_name(self, opener_team, sample_cards):
|
||||||
|
"""The embed body should mention the opener's team name."""
|
||||||
|
embed, _ = build_scout_embed(opener_team, sample_cards)
|
||||||
|
assert opener_team["lname"] in embed.description
|
||||||
|
|
||||||
|
def test_embed_description_contains_all_player_names(
|
||||||
|
self, opener_team, sample_cards
|
||||||
|
):
|
||||||
|
"""Every player name from the pack should appear in the embed."""
|
||||||
|
embed, _ = build_scout_embed(opener_team, sample_cards)
|
||||||
|
for card in sample_cards:
|
||||||
|
assert card["player"]["p_name"] in embed.description
|
||||||
|
|
||||||
|
def test_embed_mentions_token_cost(self, opener_team, sample_cards):
|
||||||
|
"""The embed should tell users about the scout token cost."""
|
||||||
|
embed, _ = build_scout_embed(opener_team, sample_cards)
|
||||||
|
assert "Scout Token" in embed.description
|
||||||
|
|
||||||
|
def test_embed_mentions_time_limit(self, opener_team, sample_cards):
|
||||||
|
"""The embed should mention the 30-minute window."""
|
||||||
|
embed, _ = build_scout_embed(opener_team, sample_cards)
|
||||||
|
assert "30 minutes" in embed.description
|
||||||
|
|
||||||
|
def test_prebuilt_card_lines_are_reused(self, opener_team, sample_cards):
|
||||||
|
"""When card_lines are passed in, they should be reused (not rebuilt)."""
|
||||||
|
prebuilt = [(101, "Custom Line 1"), (102, "Custom Line 2")]
|
||||||
|
embed, returned_lines = build_scout_embed(
|
||||||
|
opener_team, sample_cards, card_lines=prebuilt
|
||||||
|
)
|
||||||
|
assert returned_lines is prebuilt
|
||||||
|
assert "Custom Line 1" in embed.description
|
||||||
|
assert "Custom Line 2" in embed.description
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_scouted_card_list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildScoutedCardList:
|
||||||
|
"""Tests for the card list formatter that marks scouted cards."""
|
||||||
|
|
||||||
|
def test_no_scouts_returns_plain_lines(self):
|
||||||
|
"""With no scouts, output should match the raw card lines."""
|
||||||
|
card_lines = [
|
||||||
|
(101, "\U0001f7e3 MVP — Mike Trout"),
|
||||||
|
(102, "\U0001f535 All-Star — Juan Soto"),
|
||||||
|
]
|
||||||
|
result = build_scouted_card_list(card_lines, {})
|
||||||
|
assert result == "\U0001f7e3 MVP — Mike Trout\n\U0001f535 All-Star — Juan Soto"
|
||||||
|
|
||||||
|
def test_single_scout_shows_team_name(self):
|
||||||
|
"""A card scouted once should show a checkmark and the team name."""
|
||||||
|
card_lines = [
|
||||||
|
(101, "\U0001f7e3 MVP — Mike Trout"),
|
||||||
|
(102, "\U0001f535 All-Star — Juan Soto"),
|
||||||
|
]
|
||||||
|
scouted = {101: ["Scouting Squad"]}
|
||||||
|
result = build_scouted_card_list(card_lines, scouted)
|
||||||
|
assert "\u2714\ufe0f" in result # checkmark
|
||||||
|
assert "*Scouting Squad*" in result
|
||||||
|
# Unscouted card should appear plain
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert "\u2714" not in lines[1]
|
||||||
|
|
||||||
|
def test_multiple_scouts_shows_count_and_names(self):
|
||||||
|
"""A card scouted multiple times should show the count and all team names."""
|
||||||
|
card_lines = [(101, "\U0001f7e3 MVP — Mike Trout")]
|
||||||
|
scouted = {101: ["Team A", "Team B", "Team C"]}
|
||||||
|
result = build_scouted_card_list(card_lines, scouted)
|
||||||
|
assert "x3" in result
|
||||||
|
assert "*Team A*" in result
|
||||||
|
assert "*Team B*" in result
|
||||||
|
assert "*Team C*" in result
|
||||||
|
|
||||||
|
def test_mixed_scouted_and_unscouted(self):
|
||||||
|
"""Only scouted cards should have marks; unscouted cards stay plain."""
|
||||||
|
card_lines = [
|
||||||
|
(101, "Line A"),
|
||||||
|
(102, "Line B"),
|
||||||
|
(103, "Line C"),
|
||||||
|
]
|
||||||
|
scouted = {102: ["Some Team"]}
|
||||||
|
result = build_scouted_card_list(card_lines, scouted)
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert "\u2714" not in lines[0]
|
||||||
|
assert "\u2714" in lines[1]
|
||||||
|
assert "\u2714" not in lines[2]
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
"""Empty card lines should produce an empty string."""
|
||||||
|
assert build_scouted_card_list([], {}) == ""
|
||||||
|
|
||||||
|
def test_two_scouts_shows_count(self):
|
||||||
|
"""Two scouts on the same card should show x2."""
|
||||||
|
card_lines = [(101, "Line A")]
|
||||||
|
scouted = {101: ["Team X", "Team Y"]}
|
||||||
|
result = build_scouted_card_list(card_lines, scouted)
|
||||||
|
assert "x2" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_scout_opportunity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateScoutOpportunity:
|
||||||
|
"""Tests for the async scout opportunity creation flow."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_posts_to_api_and_sends_message(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
|
||||||
|
):
|
||||||
|
"""Should POST to scout_opportunities and send a message to the channel."""
|
||||||
|
mock_db_post.return_value = {"id": 42}
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, mock_channel, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
# API was called to create the opportunity
|
||||||
|
mock_db_post.assert_called_once()
|
||||||
|
call_args = mock_db_post.call_args
|
||||||
|
assert call_args[0][0] == "scout_opportunities"
|
||||||
|
assert call_args[1]["payload"]["opener_team_id"] == opener_team["id"]
|
||||||
|
|
||||||
|
# Message was sent to the channel
|
||||||
|
mock_channel.send.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_skips_wrong_channel(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_bot
|
||||||
|
):
|
||||||
|
"""Should silently return when the channel is not #pack-openings."""
|
||||||
|
channel = AsyncMock(spec=discord.TextChannel)
|
||||||
|
channel.name = "general"
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, channel, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db_post.assert_not_called()
|
||||||
|
channel.send.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_skips_empty_pack(
|
||||||
|
self, mock_db_post, opener_team, mock_channel, mock_bot
|
||||||
|
):
|
||||||
|
"""Should silently return when pack_cards is empty."""
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
await create_scout_opportunity(
|
||||||
|
[], opener_team, mock_channel, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_skips_none_channel(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_bot
|
||||||
|
):
|
||||||
|
"""Should handle None channel without crashing."""
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, None, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_api_failure_does_not_raise(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
|
||||||
|
):
|
||||||
|
"""Scout creation failure must never crash the pack opening flow."""
|
||||||
|
mock_db_post.side_effect = Exception("API down")
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, mock_channel, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_channel_send_failure_does_not_raise(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
|
||||||
|
):
|
||||||
|
"""If the channel.send fails, it should be caught gracefully."""
|
||||||
|
mock_db_post.return_value = {"id": 42}
|
||||||
|
mock_channel.send.side_effect = discord.HTTPException(
|
||||||
|
Mock(status=500), "Server error"
|
||||||
|
)
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, mock_channel, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_context_client_fallback(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
|
||||||
|
):
|
||||||
|
"""When context.bot is None, should fall back to context.client for the bot ref."""
|
||||||
|
mock_db_post.return_value = {"id": 42}
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock(spec=[]) # empty spec — no .bot attribute
|
||||||
|
context.client = mock_bot
|
||||||
|
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, mock_channel, opener_user, context
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_channel.send.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
|
||||||
|
async def test_view_message_is_assigned(
|
||||||
|
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
|
||||||
|
):
|
||||||
|
"""The message returned by channel.send should be assigned to view.message.
|
||||||
|
|
||||||
|
This linkage is required for update_message and on_timeout to work.
|
||||||
|
"""
|
||||||
|
mock_db_post.return_value = {"id": 42}
|
||||||
|
sent_msg = AsyncMock(spec=discord.Message)
|
||||||
|
mock_channel.send.return_value = sent_msg
|
||||||
|
opener_user = Mock()
|
||||||
|
opener_user.id = 99999
|
||||||
|
context = Mock()
|
||||||
|
context.bot = mock_bot
|
||||||
|
|
||||||
|
await create_scout_opportunity(
|
||||||
|
sample_cards, opener_team, mock_channel, opener_user, context
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user