Merge pull request 'Release: Scouting feature + bug fixes + cleanup' (#74) from next-release into main
All checks were successful
Build Docker Image / build (push) Successful in 46s
All checks were successful
Build Docker Image / build (push) Successful in 46s
Reviewed-on: #74
This commit is contained in:
commit
5ce270d183
@ -12,6 +12,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next-release
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@ -39,35 +40,25 @@ jobs:
|
||||
id: calver
|
||||
uses: cal/gitea-actions/calver@main
|
||||
|
||||
# Dev build: push with dev + dev-SHA tags (PR/feature branches)
|
||||
- name: Build Docker image (dev)
|
||||
if: github.ref != 'refs/heads/main'
|
||||
uses: https://github.com/docker/build-push-action@v5
|
||||
- name: Resolve Docker tags
|
||||
id: tags
|
||||
uses: cal/gitea-actions/docker-tags@main
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
manticorum67/paper-dynasty-discordapp:dev
|
||||
manticorum67/paper-dynasty-discordapp:dev-${{ steps.calver.outputs.sha_short }}
|
||||
cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache
|
||||
cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max
|
||||
image: manticorum67/paper-dynasty-discordapp
|
||||
version: ${{ steps.calver.outputs.version }}
|
||||
sha_short: ${{ steps.calver.outputs.sha_short }}
|
||||
|
||||
# Production build: push with latest + CalVer tags (main only)
|
||||
- name: Build Docker image (production)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: Build and push Docker image
|
||||
uses: https://github.com/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
manticorum67/paper-dynasty-discordapp:latest
|
||||
manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }}
|
||||
manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }}
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache
|
||||
cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max
|
||||
|
||||
- name: Tag release
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
if: success() && steps.tags.outputs.channel == 'stable'
|
||||
uses: cal/gitea-actions/gitea-tag@main
|
||||
with:
|
||||
version: ${{ steps.calver.outputs.version }}
|
||||
@ -77,26 +68,23 @@ jobs:
|
||||
run: |
|
||||
echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "Pushed to Docker Hub!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Discord Notification - Success
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
if: success() && steps.tags.outputs.channel != 'dev'
|
||||
uses: cal/gitea-actions/discord-notify@main
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
@ -108,7 +96,7 @@ jobs:
|
||||
timestamp: ${{ steps.calver.outputs.timestamp }}
|
||||
|
||||
- name: Discord Notification - Failure
|
||||
if: failure() && github.ref == 'refs/heads/main'
|
||||
if: failure() && steps.tags.outputs.channel != 'dev'
|
||||
uses: cal/gitea-actions/discord-notify@main
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
32
api_calls.py
32
api_calls.py
@ -17,7 +17,6 @@ DB_URL = (
|
||||
if "prod" in ENV_DATABASE
|
||||
else "https://pddev.manticorum.com/api"
|
||||
)
|
||||
master_debug = True
|
||||
PLAYER_CACHE = {}
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
@ -53,14 +52,10 @@ def log_return_value(log_string: str):
|
||||
line = log_string[start:end]
|
||||
if len(line) == 0:
|
||||
return
|
||||
logger.info(f"{'\n\nreturn: ' if start == 0 else ''}{log_string[start:end]}")
|
||||
logger.debug(f"{'\n\nreturn: ' if start == 0 else ''}{log_string[start:end]}")
|
||||
start += 3000
|
||||
end += 3000
|
||||
logger.warning("[ S N I P P E D ]")
|
||||
# if master_debug:
|
||||
# logger.info(f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n')
|
||||
# else:
|
||||
# logger.debug(f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n')
|
||||
|
||||
|
||||
async def db_get(
|
||||
@ -93,7 +88,7 @@ async def db_get(
|
||||
"""
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
|
||||
log_string = f"db_get - get: {endpoint} id: {object_id} params: {params}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
logger.debug(log_string)
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
@ -126,6 +121,9 @@ async def db_get(
|
||||
f"Connection timeout to host {req_url} after {retries} attempts"
|
||||
)
|
||||
raise APITimeoutError(f"Connection timeout to host {req_url}")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Connection error on GET {req_url}: {e}")
|
||||
raise DatabaseError(f"Connection error: {e}")
|
||||
|
||||
|
||||
async def db_patch(
|
||||
@ -147,7 +145,7 @@ async def db_patch(
|
||||
"""
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
|
||||
log_string = f"db_patch - patch: {endpoint} {params}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
logger.debug(log_string)
|
||||
|
||||
try:
|
||||
client_timeout = ClientTimeout(total=timeout)
|
||||
@ -166,6 +164,9 @@ async def db_patch(
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to host {req_url}")
|
||||
raise APITimeoutError(f"Connection timeout to host {req_url}")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Connection error on PATCH {req_url}: {e}")
|
||||
raise DatabaseError(f"Connection error: {e}")
|
||||
|
||||
|
||||
async def db_post(
|
||||
@ -186,7 +187,7 @@ async def db_post(
|
||||
"""
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver)
|
||||
log_string = f"db_post - post: {endpoint} payload: {payload}\ntype: {type(payload)}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
logger.debug(log_string)
|
||||
|
||||
try:
|
||||
client_timeout = ClientTimeout(total=timeout)
|
||||
@ -205,6 +206,9 @@ async def db_post(
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to host {req_url}")
|
||||
raise APITimeoutError(f"Connection timeout to host {req_url}")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Connection error on POST {req_url}: {e}")
|
||||
raise DatabaseError(f"Connection error: {e}")
|
||||
|
||||
|
||||
async def db_put(
|
||||
@ -225,7 +229,7 @@ async def db_put(
|
||||
"""
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver)
|
||||
log_string = f"db_put - put: {endpoint} payload: {payload}\ntype: {type(payload)}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
logger.debug(log_string)
|
||||
|
||||
try:
|
||||
client_timeout = ClientTimeout(total=timeout)
|
||||
@ -244,6 +248,9 @@ async def db_put(
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to host {req_url}")
|
||||
raise APITimeoutError(f"Connection timeout to host {req_url}")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Connection error on PUT {req_url}: {e}")
|
||||
raise DatabaseError(f"Connection error: {e}")
|
||||
|
||||
|
||||
async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout: int = 5):
|
||||
@ -262,7 +269,7 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout: in
|
||||
"""
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id)
|
||||
log_string = f"db_delete - delete: {endpoint} {object_id}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
logger.debug(log_string)
|
||||
|
||||
try:
|
||||
client_timeout = ClientTimeout(total=timeout)
|
||||
@ -281,6 +288,9 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout: in
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to host {req_url}")
|
||||
raise APITimeoutError(f"Connection timeout to host {req_url}")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Connection error on DELETE {req_url}: {e}")
|
||||
raise DatabaseError(f"Connection error: {e}")
|
||||
|
||||
|
||||
async def get_team_by_abbrev(abbrev: str):
|
||||
|
||||
90
cogs/economy_new/scouting.py
Normal file
90
cogs/economy_new/scouting.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""
|
||||
Scouting Cog — Scout token management and expired opportunity cleanup.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
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.discord_utils import get_team_embed
|
||||
from helpers.main import get_team_by_owner
|
||||
from helpers.constants import PD_SEASON, IMAGES
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
|
||||
class Scouting(commands.Cog):
|
||||
"""Scout token tracking and expired opportunity cleanup."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.cleanup_expired.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
self.cleanup_expired.cancel()
|
||||
|
||||
@app_commands.command(
|
||||
name="scout-tokens",
|
||||
description="Check how many scout tokens you have left today",
|
||||
)
|
||||
async def scout_tokens_command(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
team = await get_team_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send(
|
||||
"You need a Paper Dynasty team first!",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
tokens_used = await get_scout_tokens_used(team["id"])
|
||||
tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used)
|
||||
|
||||
embed = get_team_embed(title="Scout Tokens", team=team)
|
||||
embed.description = (
|
||||
f"**{tokens_remaining}** of **{SCOUT_TOKENS_PER_DAY}** tokens remaining today.\n\n"
|
||||
f"Tokens reset at midnight Central."
|
||||
)
|
||||
|
||||
if tokens_remaining == 0:
|
||||
embed.description += "\n\nYou've used all your tokens! Check back tomorrow."
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@tasks.loop(minutes=15)
|
||||
async def cleanup_expired(self):
|
||||
"""Log expired unclaimed scout opportunities.
|
||||
|
||||
This is a safety net — the ScoutView's on_timeout handles the UI side.
|
||||
If the bot restarted mid-scout, those views are lost; this just logs it.
|
||||
"""
|
||||
try:
|
||||
now = int_timestamp(datetime.datetime.now())
|
||||
expired = await db_get(
|
||||
"scout_opportunities",
|
||||
params=[
|
||||
("claimed", False),
|
||||
("expired_before", now),
|
||||
],
|
||||
)
|
||||
if expired and expired.get("count", 0) > 0:
|
||||
logger.info(
|
||||
f"Found {expired['count']} expired unclaimed scout opportunities"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Scout cleanup check failed (API may not be ready): {e}")
|
||||
|
||||
@cleanup_expired.before_loop
|
||||
async def before_cleanup(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Scouting(bot))
|
||||
File diff suppressed because it is too large
Load Diff
@ -75,7 +75,6 @@ from utilities.dropdown import (
|
||||
from utilities.embeds import image_embed
|
||||
from utilities.pages import Pagination
|
||||
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
WPA_DF = pd.read_csv(f"storage/wpa_data.csv").set_index("index")
|
||||
TO_BASE = {2: "to second", 3: "to third", 4: "home"}
|
||||
@ -612,9 +611,11 @@ async def read_lineup(
|
||||
this_game,
|
||||
this_team=lineup_team,
|
||||
lineup_num=lineup_num,
|
||||
roster_num=this_game.away_roster_id
|
||||
if this_game.home_team.is_ai
|
||||
else this_game.home_roster_id,
|
||||
roster_num=(
|
||||
this_game.away_roster_id
|
||||
if this_game.home_team.is_ai
|
||||
else this_game.home_roster_id
|
||||
),
|
||||
)
|
||||
|
||||
await interaction.edit_original_response(
|
||||
@ -759,7 +760,11 @@ def complete_play(session: Session, this_play: Play):
|
||||
opponent_play = get_last_team_play(
|
||||
session, this_play.game, this_play.pitcher.team
|
||||
)
|
||||
nbo = opponent_play.batting_order + 1 if opponent_play.pa == 1 else opponent_play.batting_order
|
||||
nbo = (
|
||||
opponent_play.batting_order + 1
|
||||
if opponent_play.pa == 1
|
||||
else opponent_play.batting_order
|
||||
)
|
||||
except PlayNotFoundException as e:
|
||||
logger.info(
|
||||
f"logic_gameplay - complete_play - No last play found for {this_play.pitcher.team.sname}, setting upcoming batting order to 1"
|
||||
@ -1106,7 +1111,8 @@ async def get_lineups_from_sheets(
|
||||
position = row[0].upper()
|
||||
if position != "DH":
|
||||
player_positions = [
|
||||
getattr(this_card.player, f"pos_{i}") for i in range(1, 9)
|
||||
getattr(this_card.player, f"pos_{i}")
|
||||
for i in range(1, 9)
|
||||
if getattr(this_card.player, f"pos_{i}") is not None
|
||||
]
|
||||
if position not in player_positions:
|
||||
@ -1156,8 +1162,6 @@ async def get_full_roster_from_sheets(
|
||||
"""
|
||||
logger.debug(f"get_full_roster_from_sheets - sheets: {sheets}")
|
||||
|
||||
this_sheet = sheets.open_by_key(this_team.gsheet)
|
||||
|
||||
this_sheet = sheets.open_by_key(this_team.gsheet)
|
||||
logger.debug(f"this_sheet: {this_sheet}")
|
||||
|
||||
@ -1216,7 +1220,10 @@ async def get_full_roster_from_sheets(
|
||||
|
||||
|
||||
async def checks_log_interaction(
|
||||
session: Session, interaction: discord.Interaction, command_name: str, lock_play: bool = True
|
||||
session: Session,
|
||||
interaction: discord.Interaction,
|
||||
command_name: str,
|
||||
lock_play: bool = True,
|
||||
) -> tuple[Game, Team, Play]:
|
||||
"""
|
||||
Validates interaction permissions and optionally locks the current play for processing.
|
||||
@ -3862,7 +3869,14 @@ async def xchecks(
|
||||
this_play.run,
|
||||
this_play.triple,
|
||||
this_play.batter_final,
|
||||
) = 1, 1, 1, 1, 1, 4
|
||||
) = (
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
4,
|
||||
)
|
||||
this_play = advance_runners(session, this_play, num_bases=4, earned_bases=3)
|
||||
|
||||
session.add(this_play)
|
||||
|
||||
@ -7,17 +7,34 @@ This package contains all Discord UI classes and components used throughout the
|
||||
from .confirmations import Question, Confirm, ButtonOptions
|
||||
from .pagination import Pagination
|
||||
from .selectors import (
|
||||
SelectChoicePackTeam, SelectOpenPack, SelectPaperdexCardset,
|
||||
SelectPaperdexTeam, SelectBuyPacksCardset, SelectBuyPacksTeam,
|
||||
SelectUpdatePlayerTeam, SelectView
|
||||
SelectChoicePackTeam,
|
||||
SelectOpenPack,
|
||||
SelectPaperdexCardset,
|
||||
SelectPaperdexTeam,
|
||||
SelectBuyPacksCardset,
|
||||
SelectBuyPacksTeam,
|
||||
SelectUpdatePlayerTeam,
|
||||
SelectView,
|
||||
)
|
||||
from .dropdowns import Dropdown, DropdownView
|
||||
|
||||
# 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__ = [
|
||||
'Question', 'Confirm', 'ButtonOptions',
|
||||
'Pagination',
|
||||
'SelectChoicePackTeam', 'SelectOpenPack', 'SelectPaperdexCardset',
|
||||
'SelectPaperdexTeam', 'SelectBuyPacksCardset', 'SelectBuyPacksTeam',
|
||||
'SelectUpdatePlayerTeam', 'SelectView',
|
||||
'Dropdown', 'DropdownView'
|
||||
]
|
||||
"Question",
|
||||
"Confirm",
|
||||
"ButtonOptions",
|
||||
"Pagination",
|
||||
"SelectChoicePackTeam",
|
||||
"SelectOpenPack",
|
||||
"SelectPaperdexCardset",
|
||||
"SelectPaperdexTeam",
|
||||
"SelectBuyPacksCardset",
|
||||
"SelectBuyPacksTeam",
|
||||
"SelectUpdatePlayerTeam",
|
||||
"SelectView",
|
||||
"Dropdown",
|
||||
"DropdownView",
|
||||
]
|
||||
|
||||
272
discord_ui/scout_view.py
Normal file
272
discord_ui/scout_view.py
Normal file
@ -0,0 +1,272 @@
|
||||
"""
|
||||
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.
|
||||
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 the same
|
||||
card — each gets their own copy.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
|
||||
from api_calls import db_post
|
||||
from helpers.main import get_team_by_owner, get_card_embeds
|
||||
from helpers.scouting import (
|
||||
SCOUT_TOKENS_PER_DAY,
|
||||
build_scout_embed,
|
||||
get_scout_tokens_used,
|
||||
)
|
||||
from helpers.utils import int_timestamp
|
||||
from helpers.discord_utils import get_team_embed, send_to_channel
|
||||
from helpers.constants import IMAGES, PD_SEASON
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
|
||||
class ScoutView(discord.ui.View):
|
||||
"""Displays face-down card buttons for a scout opportunity.
|
||||
|
||||
- One button per card, labeled "Card 1" ... "Card N"
|
||||
- Any player EXCEPT the pack opener can interact
|
||||
- Any card can be scouted multiple times by different players
|
||||
- One scout per player per pack
|
||||
- Timeout: 30 minutes
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scout_opp_id: int,
|
||||
cards: list[dict],
|
||||
opener_team: dict,
|
||||
opener_user_id: int,
|
||||
bot,
|
||||
expires_unix: int = None,
|
||||
):
|
||||
super().__init__(timeout=1800.0)
|
||||
self.scout_opp_id = scout_opp_id
|
||||
self.cards = cards
|
||||
self.opener_team = opener_team
|
||||
self.opener_user_id = opener_user_id
|
||||
self.bot = bot
|
||||
self.expires_unix = expires_unix
|
||||
self.message: discord.Message | None = None
|
||||
self.card_lines: list[tuple[int, str]] = []
|
||||
|
||||
# Per-card claim tracking: player_id -> list of scouter team names
|
||||
self.claims: dict[int, list[str]] = {}
|
||||
# Per-user lock: user IDs who have already scouted this pack
|
||||
self.scouted_users: set[int] = set()
|
||||
# Users currently being processed (prevent double-click race)
|
||||
self.processing_users: set[int] = set()
|
||||
|
||||
for i, card in enumerate(cards):
|
||||
button = ScoutButton(
|
||||
card=card,
|
||||
position=i,
|
||||
scout_view=self,
|
||||
)
|
||||
self.add_item(button)
|
||||
|
||||
@property
|
||||
def total_scouts(self) -> int:
|
||||
return sum(len(v) for v in self.claims.values())
|
||||
|
||||
async def update_message(self):
|
||||
"""Refresh the embed with current claim state."""
|
||||
if not self.message:
|
||||
return
|
||||
|
||||
embed, _ = build_scout_embed(
|
||||
self.opener_team,
|
||||
card_lines=self.card_lines,
|
||||
expires_unix=self.expires_unix,
|
||||
claims=self.claims,
|
||||
total_scouts=self.total_scouts,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.message.edit(embed=embed, view=self)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update scout message: {e}")
|
||||
|
||||
async def on_timeout(self):
|
||||
"""Disable all buttons and update the embed when the window expires."""
|
||||
for item in self.children:
|
||||
item.disabled = True
|
||||
|
||||
if self.message:
|
||||
try:
|
||||
embed, _ = build_scout_embed(
|
||||
self.opener_team,
|
||||
card_lines=self.card_lines,
|
||||
claims=self.claims,
|
||||
total_scouts=self.total_scouts,
|
||||
closed=True,
|
||||
)
|
||||
await self.message.edit(embed=embed, view=self)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to edit expired scout message: {e}")
|
||||
|
||||
|
||||
class ScoutButton(discord.ui.Button):
|
||||
"""A single face-down card button in a ScoutView."""
|
||||
|
||||
def __init__(self, card: dict, position: int, scout_view: ScoutView):
|
||||
super().__init__(
|
||||
label=f"Card {position + 1}",
|
||||
style=discord.ButtonStyle.secondary,
|
||||
row=0,
|
||||
)
|
||||
self.card = card
|
||||
self.position = position
|
||||
self.scout_view: ScoutView = scout_view
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
view = self.scout_view
|
||||
|
||||
# Block the opener
|
||||
if interaction.user.id == view.opener_user_id:
|
||||
await interaction.response.send_message(
|
||||
"You can't scout your own pack!",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# One scout per player per pack
|
||||
if interaction.user.id in view.scouted_users:
|
||||
await interaction.response.send_message(
|
||||
"You already scouted a card from this pack!",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Prevent double-click race for same user
|
||||
if interaction.user.id in view.processing_users:
|
||||
await interaction.response.send_message(
|
||||
"Your scout is already being processed!",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
view.processing_users.add(interaction.user.id)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
try:
|
||||
# Get scouting player's team
|
||||
scouter_team = await get_team_by_owner(interaction.user.id)
|
||||
if not scouter_team:
|
||||
await interaction.followup.send(
|
||||
"You need a Paper Dynasty team to scout! Ask an admin to set one up.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check scout token balance
|
||||
tokens_used = await get_scout_tokens_used(scouter_team["id"])
|
||||
|
||||
if tokens_used >= SCOUT_TOKENS_PER_DAY:
|
||||
await interaction.followup.send(
|
||||
"You're out of scout tokens for today! You get 2 per day, resetting at midnight Central.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Record the claim in the database
|
||||
try:
|
||||
await db_post(
|
||||
"scout_claims",
|
||||
payload={
|
||||
"scout_opportunity_id": view.scout_opp_id,
|
||||
"card_id": self.card["id"],
|
||||
"claimed_by_team_id": scouter_team["id"],
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record scout claim: {e}")
|
||||
await interaction.followup.send(
|
||||
"Something went wrong claiming this scout. Try again!",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Create a copy of the card for the scouter (before consuming token
|
||||
# so a failure here doesn't cost the player a token for nothing)
|
||||
await db_post(
|
||||
"cards",
|
||||
payload={
|
||||
"cards": [
|
||||
{
|
||||
"player_id": self.card["player"]["player_id"],
|
||||
"team_id": scouter_team["id"],
|
||||
"pack_id": self.card["pack"]["id"],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# Consume a scout token
|
||||
await db_post(
|
||||
"rewards",
|
||||
payload={
|
||||
"name": "Scout Token",
|
||||
"team_id": scouter_team["id"],
|
||||
"season": PD_SEASON,
|
||||
"created": int_timestamp(),
|
||||
},
|
||||
)
|
||||
|
||||
# Track the claim
|
||||
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)
|
||||
|
||||
# Update the shared embed
|
||||
await view.update_message()
|
||||
|
||||
# Send the scouter their card details (ephemeral)
|
||||
player_name = self.card["player"]["p_name"]
|
||||
rarity_name = self.card["player"]["rarity"]["name"]
|
||||
|
||||
card_for_embed = {
|
||||
"player": self.card["player"],
|
||||
"team": scouter_team,
|
||||
}
|
||||
card_embeds = await get_card_embeds(card_for_embed)
|
||||
await interaction.followup.send(
|
||||
content=f"You scouted a **{rarity_name}** {player_name}!",
|
||||
embeds=card_embeds,
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# Notify for shiny scouts (rarity >= 5)
|
||||
if self.card["player"]["rarity"]["value"] >= 5:
|
||||
try:
|
||||
notif_embed = get_team_embed(title="Rare Scout!", team=scouter_team)
|
||||
notif_embed.description = (
|
||||
f"**{scouter_team['lname']}** scouted a "
|
||||
f"**{rarity_name}** {player_name}!"
|
||||
)
|
||||
notif_embed.set_thumbnail(
|
||||
url=self.card["player"].get("headshot", IMAGES["logo"])
|
||||
)
|
||||
await send_to_channel(
|
||||
view.bot, "pd-network-news", embed=notif_embed
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send shiny scout notification: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in scout callback: {e}", exc_info=True)
|
||||
try:
|
||||
await interaction.followup.send(
|
||||
"Something went wrong. Please try again.",
|
||||
ephemeral=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
view.processing_users.discard(interaction.user.id)
|
||||
@ -6,7 +6,7 @@ The package is organized into logical modules for better maintainability.
|
||||
|
||||
Modules:
|
||||
- constants: Application constants and configuration
|
||||
- utils: General utility functions
|
||||
- utils: General utility functions
|
||||
- random_content: Random content generators
|
||||
- search_utils: Search and fuzzy matching functionality
|
||||
- discord_utils: Discord helper functions
|
||||
@ -21,9 +21,10 @@ Modules:
|
||||
# This allows existing code to continue working during the migration
|
||||
from helpers.main import *
|
||||
|
||||
# Import from migrated modules
|
||||
# Import from migrated modules
|
||||
from .constants import *
|
||||
from .utils import *
|
||||
from .random_content import *
|
||||
from .search_utils import *
|
||||
from .discord_utils import *
|
||||
from .search_utils import *
|
||||
from .discord_utils import *
|
||||
from .scouting import *
|
||||
|
||||
@ -8,7 +8,7 @@ import traceback
|
||||
|
||||
import discord
|
||||
import pygsheets
|
||||
import requests
|
||||
import aiohttp
|
||||
from discord.ext import commands
|
||||
from api_calls import *
|
||||
|
||||
@ -43,17 +43,21 @@ async def get_player_photo(player):
|
||||
)
|
||||
|
||||
try:
|
||||
resp = requests.get(req_url, timeout=0.5)
|
||||
except Exception as e:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
req_url, timeout=aiohttp.ClientTimeout(total=0.5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
if data["player"] and data["player"][0]["strSport"] == "Baseball":
|
||||
await db_patch(
|
||||
"players",
|
||||
object_id=player["player_id"],
|
||||
params=[("headshot", data["player"][0]["strThumb"])],
|
||||
)
|
||||
return data["player"][0]["strThumb"]
|
||||
except Exception:
|
||||
return None
|
||||
if resp.status_code == 200 and resp.json()["player"]:
|
||||
if resp.json()["player"][0]["strSport"] == "Baseball":
|
||||
await db_patch(
|
||||
"players",
|
||||
object_id=player["player_id"],
|
||||
params=[("headshot", resp.json()["player"][0]["strThumb"])],
|
||||
)
|
||||
return resp.json()["player"][0]["strThumb"]
|
||||
return None
|
||||
|
||||
|
||||
@ -1681,9 +1685,9 @@ async def paperdex_team_embed(team: dict, mlb_team: dict) -> list[discord.Embed]
|
||||
for cardset_id in coll_data:
|
||||
if cardset_id != "total_owned":
|
||||
if coll_data[cardset_id]["players"]:
|
||||
coll_data[cardset_id]["embeds"][0].description = (
|
||||
f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}"
|
||||
)
|
||||
coll_data[cardset_id]["embeds"][
|
||||
0
|
||||
].description = f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}"
|
||||
coll_data[cardset_id]["embeds"][0].add_field(
|
||||
name="# Collected / # Total Cards",
|
||||
value=f"{coll_data[cardset_id]['owned']} / {len(coll_data[cardset_id]['players'])}",
|
||||
@ -1749,6 +1753,8 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
|
||||
all_cards = []
|
||||
for p_id in pack_ids:
|
||||
new_cards = await db_get("cards", params=[("pack_id", p_id)])
|
||||
for card in new_cards["cards"]:
|
||||
card.setdefault("pack_id", p_id)
|
||||
all_cards.extend(new_cards["cards"])
|
||||
|
||||
if not all_cards:
|
||||
@ -1764,6 +1770,20 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
|
||||
await context.channel.send(content=f"Let's head down to {pack_channel.mention}!")
|
||||
await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover)
|
||||
|
||||
# Create scout opportunities for each pack (Standard/Premium only)
|
||||
from helpers.scouting import create_scout_opportunity, SCOUTABLE_PACK_TYPES
|
||||
|
||||
pack_type_name = all_packs[0].get("pack_type", {}).get("name")
|
||||
if pack_type_name in SCOUTABLE_PACK_TYPES:
|
||||
for p_id in pack_ids:
|
||||
pack_cards = [c for c in all_cards if c.get("pack_id") == p_id]
|
||||
if pack_cards:
|
||||
await create_scout_opportunity(
|
||||
pack_cards, team, pack_channel, author, context
|
||||
)
|
||||
if len(pack_ids) > 1:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
async def get_choice_from_cards(
|
||||
interaction: discord.Interaction,
|
||||
|
||||
243
helpers/scouting.py
Normal file
243
helpers/scouting.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""
|
||||
Scouting Helper Functions
|
||||
|
||||
Handles creation of scout opportunities after pack openings
|
||||
and embed formatting for the scouting feature.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
||||
import discord
|
||||
|
||||
from api_calls import db_get, db_post
|
||||
from helpers.utils import int_timestamp, midnight_timestamp
|
||||
from helpers.discord_utils import get_team_embed
|
||||
from helpers.constants import IMAGES, PD_SEASON
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
SCOUT_TOKENS_PER_DAY = 2
|
||||
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
|
||||
_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium")
|
||||
SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()}
|
||||
|
||||
# Rarity value → display symbol
|
||||
RARITY_SYMBOLS = {
|
||||
8: "\U0001f7e3", # HoF — purple (#751cea)
|
||||
5: "\U0001f535", # MVP — cyan/blue (#56f1fa)
|
||||
3: "\U0001f7e1", # All-Star — gold (#FFD700)
|
||||
2: "\u26aa", # Starter — silver (#C0C0C0)
|
||||
1: "\U0001f7e4", # Reserve — bronze (#CD7F32)
|
||||
0: "\u26ab", # Replacement — dark gray (#454545)
|
||||
}
|
||||
|
||||
|
||||
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]]:
|
||||
"""Build a shuffled list of (player_id, display_line) tuples."""
|
||||
lines = []
|
||||
for card in cards:
|
||||
player = card["player"]
|
||||
rarity_val = player["rarity"]["value"]
|
||||
symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab")
|
||||
desc = player.get("description", "")
|
||||
image_url = player.get("image", "")
|
||||
name_display = (
|
||||
f"[{desc} {player['p_name']}]({image_url})"
|
||||
if image_url
|
||||
else f"{desc} {player['p_name']}"
|
||||
)
|
||||
lines.append(
|
||||
(
|
||||
player["player_id"],
|
||||
f"{symbol} {player['rarity']['name']} — {name_display}",
|
||||
)
|
||||
)
|
||||
random.shuffle(lines)
|
||||
return lines
|
||||
|
||||
|
||||
def build_scout_embed(
|
||||
opener_team: dict,
|
||||
cards: list[dict] = None,
|
||||
card_lines: list[tuple[int, str]] = None,
|
||||
expires_unix: int = None,
|
||||
claims: dict[int, list[str]] = None,
|
||||
total_scouts: int = 0,
|
||||
closed: bool = False,
|
||||
) -> tuple[discord.Embed, list[tuple[int, str]]]:
|
||||
"""Build the embed shown above the scout buttons.
|
||||
|
||||
Shows a shuffled list of cards (rarity + player name) so scouters
|
||||
know what's in the pack but not which button maps to which card.
|
||||
Returns (embed, card_lines) so the view can store the shuffled order.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
closed : if True, renders the "Scout Window Closed" variant
|
||||
claims : scouted card tracking dict for build_scouted_card_list
|
||||
total_scouts : number of scouts so far (for title display)
|
||||
"""
|
||||
if card_lines is None and cards is not None:
|
||||
card_lines = _build_card_lines(cards)
|
||||
|
||||
if claims and card_lines:
|
||||
card_list = build_scouted_card_list(card_lines, claims)
|
||||
elif card_lines:
|
||||
card_list = "\n".join(line for _, line in card_lines)
|
||||
else:
|
||||
card_list = ""
|
||||
|
||||
if closed:
|
||||
if total_scouts > 0:
|
||||
title = f"Scout Window Closed ({total_scouts} scouted)"
|
||||
else:
|
||||
title = "Scout Window Closed"
|
||||
elif total_scouts > 0:
|
||||
title = f"Scout Opportunity! ({total_scouts} scouted)"
|
||||
else:
|
||||
title = "Scout Opportunity!"
|
||||
|
||||
embed = get_team_embed(title=title, team=opener_team)
|
||||
|
||||
if closed:
|
||||
embed.description = f"**{opener_team['lname']}**'s pack\n\n" f"{card_list}"
|
||||
embed.set_footer(
|
||||
text=f"Paper Dynasty Season {PD_SEASON}",
|
||||
icon_url=IMAGES["logo"],
|
||||
)
|
||||
else:
|
||||
if expires_unix:
|
||||
time_line = f"Scout window closes <t:{expires_unix}:R>."
|
||||
else:
|
||||
time_line = "Scout window closes in **30 minutes**."
|
||||
|
||||
embed.description = (
|
||||
f"**{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"],
|
||||
)
|
||||
return embed, card_lines
|
||||
|
||||
|
||||
def build_scouted_card_list(
|
||||
card_lines: list[tuple[int, str]],
|
||||
scouted_cards: dict[int, list[str]],
|
||||
) -> str:
|
||||
"""Rebuild the card list marking scouted cards with scouter team names.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
card_lines : shuffled list of (player_id, display_line) tuples
|
||||
scouted_cards : {player_id: [team_name, ...]} for each claimed card
|
||||
"""
|
||||
result = []
|
||||
for player_id, line in card_lines:
|
||||
teams = scouted_cards.get(player_id)
|
||||
if teams:
|
||||
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:
|
||||
result.append(line)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
async def create_scout_opportunity(
|
||||
pack_cards: list[dict],
|
||||
opener_team: dict,
|
||||
channel: discord.TextChannel,
|
||||
opener_user,
|
||||
context,
|
||||
) -> None:
|
||||
"""Create a scout opportunity and post the ScoutView to the channel.
|
||||
|
||||
Called after display_cards() completes in open_st_pr_packs().
|
||||
Wrapped in try/except so scouting failures never crash pack opening.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pack_cards : list of card dicts from a single pack
|
||||
opener_team : team dict for the pack opener
|
||||
channel : the #pack-openings channel
|
||||
opener_user : discord.Member or discord.User who opened the pack
|
||||
context : the command context (Context or Interaction), used to get bot
|
||||
"""
|
||||
from discord_ui.scout_view import ScoutView
|
||||
|
||||
# Only create scout opportunities in the pack-openings channel
|
||||
if not channel or channel.name != "pack-openings":
|
||||
return
|
||||
|
||||
if not pack_cards:
|
||||
return
|
||||
|
||||
now = datetime.datetime.now()
|
||||
expires_dt = now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)
|
||||
expires_at = int_timestamp(expires_dt)
|
||||
expires_unix = int(expires_dt.timestamp())
|
||||
created = int_timestamp(now)
|
||||
|
||||
card_ids = [c["id"] for c in pack_cards]
|
||||
|
||||
try:
|
||||
scout_opp = await db_post(
|
||||
"scout_opportunities",
|
||||
payload={
|
||||
"pack_id": pack_cards[0].get("pack_id"),
|
||||
"opener_team_id": opener_team["id"],
|
||||
"card_ids": card_ids,
|
||||
"expires_at": expires_at,
|
||||
"created": created,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create scout opportunity: {e}")
|
||||
return
|
||||
|
||||
embed, card_lines = build_scout_embed(
|
||||
opener_team, pack_cards, expires_unix=expires_unix
|
||||
)
|
||||
|
||||
# Get bot reference from context
|
||||
bot = getattr(context, "bot", None) or getattr(context, "client", None)
|
||||
|
||||
view = ScoutView(
|
||||
scout_opp_id=scout_opp["id"],
|
||||
cards=pack_cards,
|
||||
opener_team=opener_team,
|
||||
opener_user_id=opener_user.id,
|
||||
bot=bot,
|
||||
expires_unix=expires_unix,
|
||||
)
|
||||
view.card_lines = card_lines
|
||||
|
||||
try:
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
view.message = msg
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to post scout opportunity message: {e}")
|
||||
129
helpers/utils.py
129
helpers/utils.py
@ -4,60 +4,71 @@ General Utilities
|
||||
This module contains standalone utility functions with minimal dependencies,
|
||||
including timestamp conversion, position abbreviations, and simple helpers.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import discord
|
||||
|
||||
|
||||
def int_timestamp(datetime_obj: Optional[datetime.datetime] = None):
|
||||
"""Convert current datetime to integer timestamp."""
|
||||
if datetime_obj:
|
||||
return int(datetime.datetime.timestamp(datetime_obj) * 1000)
|
||||
return int(datetime.datetime.now().timestamp())
|
||||
"""Convert a datetime to an integer millisecond timestamp.
|
||||
|
||||
If no argument is given, uses the current time.
|
||||
"""
|
||||
if datetime_obj is None:
|
||||
datetime_obj = datetime.datetime.now()
|
||||
return int(datetime.datetime.timestamp(datetime_obj) * 1000)
|
||||
|
||||
|
||||
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:
|
||||
"""Convert position name to standard abbreviation."""
|
||||
if field_pos.lower() == 'catcher':
|
||||
return 'C'
|
||||
elif field_pos.lower() == 'first baseman':
|
||||
return '1B'
|
||||
elif field_pos.lower() == 'second baseman':
|
||||
return '2B'
|
||||
elif field_pos.lower() == 'third baseman':
|
||||
return '3B'
|
||||
elif field_pos.lower() == 'shortstop':
|
||||
return 'SS'
|
||||
elif field_pos.lower() == 'left fielder':
|
||||
return 'LF'
|
||||
elif field_pos.lower() == 'center fielder':
|
||||
return 'CF'
|
||||
elif field_pos.lower() == 'right fielder':
|
||||
return 'RF'
|
||||
if field_pos.lower() == "catcher":
|
||||
return "C"
|
||||
elif field_pos.lower() == "first baseman":
|
||||
return "1B"
|
||||
elif field_pos.lower() == "second baseman":
|
||||
return "2B"
|
||||
elif field_pos.lower() == "third baseman":
|
||||
return "3B"
|
||||
elif field_pos.lower() == "shortstop":
|
||||
return "SS"
|
||||
elif field_pos.lower() == "left fielder":
|
||||
return "LF"
|
||||
elif field_pos.lower() == "center fielder":
|
||||
return "CF"
|
||||
elif field_pos.lower() == "right fielder":
|
||||
return "RF"
|
||||
else:
|
||||
return 'P'
|
||||
return "P"
|
||||
|
||||
|
||||
def position_name_to_abbrev(position_name):
|
||||
"""Convert position name to abbreviation (alternate format)."""
|
||||
if position_name == 'Catcher':
|
||||
return 'C'
|
||||
elif position_name == 'First Base':
|
||||
return '1B'
|
||||
elif position_name == 'Second Base':
|
||||
return '2B'
|
||||
elif position_name == 'Third Base':
|
||||
return '3B'
|
||||
elif position_name == 'Shortstop':
|
||||
return 'SS'
|
||||
elif position_name == 'Left Field':
|
||||
return 'LF'
|
||||
elif position_name == 'Center Field':
|
||||
return 'CF'
|
||||
elif position_name == 'Right Field':
|
||||
return 'RF'
|
||||
elif position_name == 'Pitcher':
|
||||
return 'P'
|
||||
if position_name == "Catcher":
|
||||
return "C"
|
||||
elif position_name == "First Base":
|
||||
return "1B"
|
||||
elif position_name == "Second Base":
|
||||
return "2B"
|
||||
elif position_name == "Third Base":
|
||||
return "3B"
|
||||
elif position_name == "Shortstop":
|
||||
return "SS"
|
||||
elif position_name == "Left Field":
|
||||
return "LF"
|
||||
elif position_name == "Center Field":
|
||||
return "CF"
|
||||
elif position_name == "Right Field":
|
||||
return "RF"
|
||||
elif position_name == "Pitcher":
|
||||
return "P"
|
||||
else:
|
||||
return position_name
|
||||
|
||||
@ -67,13 +78,13 @@ def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool:
|
||||
for x in user.roles:
|
||||
if x.name == role_name:
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_roster_sheet_legacy(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):
|
||||
@ -83,13 +94,15 @@ def get_roster_sheet(team):
|
||||
Handles both dict and Team object formats.
|
||||
"""
|
||||
# Handle both dict (team["gsheet"]) and object (team.gsheet) formats
|
||||
gsheet = team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None)
|
||||
return f'https://docs.google.com/spreadsheets/d/{gsheet}/edit'
|
||||
gsheet = (
|
||||
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:
|
||||
"""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"]}'
|
||||
else:
|
||||
return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml'
|
||||
@ -98,10 +111,11 @@ def get_player_url(team, player) -> str:
|
||||
def owner_only(ctx) -> bool:
|
||||
"""Check if user is the bot owner."""
|
||||
# ID for discord User Cal
|
||||
owners = [287463767924137994, 1087936030899347516]
|
||||
owners = [258104532423147520]
|
||||
# owners += [287463767924137994, 1087936030899347516]
|
||||
|
||||
# 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:
|
||||
return True
|
||||
@ -121,35 +135,36 @@ def get_context_user(ctx):
|
||||
discord.User or discord.Member: The user who invoked the command
|
||||
"""
|
||||
# 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):
|
||||
"""Get the Cal user from context. Always returns an object with .mention attribute."""
|
||||
import logging
|
||||
logger = logging.getLogger('discord_app')
|
||||
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
# Define placeholder user class first
|
||||
class PlaceholderUser:
|
||||
def __init__(self):
|
||||
self.mention = "<@287463767924137994>"
|
||||
self.id = 287463767924137994
|
||||
|
||||
|
||||
# Handle both Context and Interaction objects
|
||||
if hasattr(ctx, 'bot'): # Context object
|
||||
if hasattr(ctx, "bot"): # Context object
|
||||
bot = ctx.bot
|
||||
logger.debug("get_cal_user: Using Context object")
|
||||
elif hasattr(ctx, 'client'): # Interaction object
|
||||
elif hasattr(ctx, "client"): # Interaction object
|
||||
bot = ctx.client
|
||||
logger.debug("get_cal_user: Using Interaction object")
|
||||
else:
|
||||
logger.error("get_cal_user: No bot or client found in context")
|
||||
return PlaceholderUser()
|
||||
|
||||
|
||||
if not bot:
|
||||
logger.error("get_cal_user: bot is None")
|
||||
return PlaceholderUser()
|
||||
|
||||
|
||||
logger.debug(f"get_cal_user: Searching among members")
|
||||
try:
|
||||
for user in bot.get_all_members():
|
||||
@ -158,7 +173,7 @@ def get_cal_user(ctx):
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"get_cal_user: Exception in get_all_members: {e}")
|
||||
|
||||
|
||||
# Fallback: try to get user directly by ID
|
||||
logger.debug("get_cal_user: User not found in get_all_members, trying get_user")
|
||||
try:
|
||||
@ -170,7 +185,7 @@ def get_cal_user(ctx):
|
||||
logger.debug("get_cal_user: get_user returned None")
|
||||
except Exception as e:
|
||||
logger.error(f"get_cal_user: Exception in get_user: {e}")
|
||||
|
||||
|
||||
# Last resort: return a placeholder user object with mention
|
||||
logger.debug("get_cal_user: Using placeholder user")
|
||||
return PlaceholderUser()
|
||||
return PlaceholderUser()
|
||||
|
||||
@ -12,12 +12,12 @@ from in_game.gameplay_queries import get_channel_game_or_none
|
||||
from health_server import run_health_server
|
||||
from notify_restart import send_restart_notification
|
||||
|
||||
raw_log_level = os.getenv('LOG_LEVEL')
|
||||
if raw_log_level == 'DEBUG':
|
||||
raw_log_level = os.getenv("LOG_LEVEL")
|
||||
if raw_log_level == "DEBUG":
|
||||
log_level = logging.DEBUG
|
||||
elif raw_log_level == 'INFO':
|
||||
elif raw_log_level == "INFO":
|
||||
log_level = logging.INFO
|
||||
elif raw_log_level == 'WARN':
|
||||
elif raw_log_level == "WARN":
|
||||
log_level = logging.WARNING
|
||||
else:
|
||||
log_level = logging.ERROR
|
||||
@ -29,17 +29,17 @@ else:
|
||||
# level=log_level
|
||||
# )
|
||||
# logger.getLogger('discord.http').setLevel(logger.INFO)
|
||||
logger = logging.getLogger('discord_app')
|
||||
logger = logging.getLogger("discord_app")
|
||||
logger.setLevel(log_level)
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
filename='logs/discord.log',
|
||||
filename="logs/discord.log",
|
||||
# encoding='utf-8',
|
||||
maxBytes=32 * 1024 * 1024, # 32 MiB
|
||||
backupCount=5, # Rotate through 5 files
|
||||
)
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# dt_fmt = '%Y-%m-%d %H:%M:%S'
|
||||
@ -48,27 +48,30 @@ handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
COGS = [
|
||||
'cogs.owner',
|
||||
'cogs.admins',
|
||||
'cogs.economy',
|
||||
'cogs.players',
|
||||
'cogs.gameplay',
|
||||
"cogs.owner",
|
||||
"cogs.admins",
|
||||
"cogs.economy",
|
||||
"cogs.players",
|
||||
"cogs.gameplay",
|
||||
"cogs.economy_new.scouting",
|
||||
]
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.members = True
|
||||
intents.message_content = True
|
||||
bot = commands.Bot(command_prefix='.',
|
||||
intents=intents,
|
||||
# help_command=None,
|
||||
description='The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.',
|
||||
case_insensitive=True,
|
||||
owner_id=258104532423147520)
|
||||
bot = commands.Bot(
|
||||
command_prefix=".",
|
||||
intents=intents,
|
||||
# help_command=None,
|
||||
description="The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.",
|
||||
case_insensitive=True,
|
||||
owner_id=258104532423147520,
|
||||
)
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
logger.info('Logged in as:')
|
||||
logger.info("Logged in as:")
|
||||
logger.info(bot.user.name)
|
||||
logger.info(bot.user.id)
|
||||
|
||||
@ -77,9 +80,11 @@ async def on_ready():
|
||||
|
||||
|
||||
@bot.tree.error
|
||||
async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError):
|
||||
async def on_app_command_error(
|
||||
interaction: discord.Interaction, error: discord.app_commands.AppCommandError
|
||||
):
|
||||
"""Global error handler for all app commands (slash commands)."""
|
||||
logger.error(f'App command error in {interaction.command}: {error}', exc_info=error)
|
||||
logger.error(f"App command error in {interaction.command}: {error}", exc_info=error)
|
||||
|
||||
# CRITICAL: Release play lock if command failed during gameplay
|
||||
# This prevents permanent user lockouts when exceptions occur
|
||||
@ -97,22 +102,23 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord.
|
||||
session.add(current_play)
|
||||
session.commit()
|
||||
except Exception as lock_error:
|
||||
logger.error(f'Failed to release play lock after error: {lock_error}', exc_info=lock_error)
|
||||
logger.error(
|
||||
f"Failed to release play lock after error: {lock_error}",
|
||||
exc_info=lock_error,
|
||||
)
|
||||
|
||||
# Try to respond to the user
|
||||
try:
|
||||
if not interaction.response.is_done():
|
||||
await interaction.response.send_message(
|
||||
f'❌ An error occurred: {str(error)}',
|
||||
ephemeral=True
|
||||
f"❌ An error occurred: {str(error)}", ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
f'❌ An error occurred: {str(error)}',
|
||||
ephemeral=True
|
||||
f"❌ An error occurred: {str(error)}", ephemeral=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send error message to user: {e}')
|
||||
logger.error(f"Failed to send error message to user: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
@ -120,10 +126,10 @@ async def main():
|
||||
for c in COGS:
|
||||
try:
|
||||
await bot.load_extension(c)
|
||||
logger.info(f'Loaded cog: {c}')
|
||||
logger.info(f"Loaded cog: {c}")
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to load cog: {c}')
|
||||
logger.error(f'{e}')
|
||||
logger.error(f"Failed to load cog: {c}")
|
||||
logger.error(f"{e}")
|
||||
|
||||
# Start health server and bot concurrently
|
||||
async with bot:
|
||||
@ -132,7 +138,7 @@ async def main():
|
||||
|
||||
try:
|
||||
# Start bot (this blocks until bot stops)
|
||||
await bot.start(os.environ.get('BOT_TOKEN', 'NONE'))
|
||||
await bot.start(os.environ.get("BOT_TOKEN", "NONE"))
|
||||
finally:
|
||||
# Cleanup: cancel health server when bot stops
|
||||
health_task.cancel()
|
||||
@ -141,4 +147,5 @@ async def main():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
@ -27,498 +27,596 @@ except ImportError:
|
||||
@pytest.mark.asyncio
|
||||
class TestTeamManagement:
|
||||
"""Test suite for TeamManagement cog functionality."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_management_cog(self, mock_bot):
|
||||
"""Create TeamManagement cog instance for testing."""
|
||||
return TeamManagement(mock_bot)
|
||||
|
||||
|
||||
async def test_init(self, team_management_cog, mock_bot):
|
||||
"""Test cog initialization."""
|
||||
assert team_management_cog.bot == mock_bot
|
||||
|
||||
@patch('api_calls.get_team_by_abbrev')
|
||||
@patch('helpers.get_team_by_owner')
|
||||
@patch('helpers.team_summary_embed')
|
||||
async def test_team_command_with_abbreviation_success(self, mock_team_summary,
|
||||
mock_get_by_owner, mock_get_by_abbrev,
|
||||
team_management_cog, mock_interaction,
|
||||
sample_team_data, mock_embed):
|
||||
|
||||
@patch("api_calls.get_team_by_abbrev")
|
||||
@patch("helpers.get_team_by_owner")
|
||||
@patch("helpers.team_summary_embed")
|
||||
async def test_team_command_with_abbreviation_success(
|
||||
self,
|
||||
mock_team_summary,
|
||||
mock_get_by_owner,
|
||||
mock_get_by_abbrev,
|
||||
team_management_cog,
|
||||
mock_interaction,
|
||||
sample_team_data,
|
||||
mock_embed,
|
||||
):
|
||||
"""Test team command with team abbreviation provided."""
|
||||
mock_get_by_abbrev.return_value = sample_team_data
|
||||
mock_team_summary.return_value = mock_embed
|
||||
|
||||
|
||||
async def mock_team_command(interaction, team_abbrev=None):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
if team_abbrev:
|
||||
team = await mock_get_by_abbrev(team_abbrev)
|
||||
if not team:
|
||||
await interaction.followup.send(f'Could not find team with abbreviation: {team_abbrev}')
|
||||
await interaction.followup.send(
|
||||
f"Could not find team with abbreviation: {team_abbrev}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet! Use `/newteam` to create one.')
|
||||
await interaction.followup.send(
|
||||
"You don't have a team yet! Use `/newteam` to create one."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
embed = await mock_team_summary(team, interaction, include_roster=True)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
await mock_team_command(mock_interaction, 'TST')
|
||||
|
||||
|
||||
await mock_team_command(mock_interaction, "TST")
|
||||
|
||||
mock_interaction.response.defer.assert_called_once()
|
||||
mock_get_by_abbrev.assert_called_once_with('TST')
|
||||
mock_get_by_abbrev.assert_called_once_with("TST")
|
||||
mock_team_summary.assert_called_once()
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
|
||||
@patch('api_calls.get_team_by_abbrev')
|
||||
async def test_team_command_abbreviation_not_found(self, mock_get_by_abbrev,
|
||||
team_management_cog, mock_interaction):
|
||||
|
||||
@patch("api_calls.get_team_by_abbrev")
|
||||
async def test_team_command_abbreviation_not_found(
|
||||
self, mock_get_by_abbrev, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test team command when abbreviation is not found."""
|
||||
mock_get_by_abbrev.return_value = None
|
||||
|
||||
|
||||
async def mock_team_command(interaction, team_abbrev):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_abbrev(team_abbrev)
|
||||
if not team:
|
||||
await interaction.followup.send(f'Could not find team with abbreviation: {team_abbrev}')
|
||||
await interaction.followup.send(
|
||||
f"Could not find team with abbreviation: {team_abbrev}"
|
||||
)
|
||||
return
|
||||
|
||||
await mock_team_command(mock_interaction, 'XYZ')
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('Could not find team with abbreviation: XYZ')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
@patch('helpers.team_summary_embed')
|
||||
async def test_team_command_without_abbreviation_success(self, mock_team_summary,
|
||||
mock_get_by_owner,
|
||||
team_management_cog, mock_interaction,
|
||||
sample_team_data, mock_embed):
|
||||
|
||||
await mock_team_command(mock_interaction, "XYZ")
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"Could not find team with abbreviation: XYZ"
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
@patch("helpers.team_summary_embed")
|
||||
async def test_team_command_without_abbreviation_success(
|
||||
self,
|
||||
mock_team_summary,
|
||||
mock_get_by_owner,
|
||||
team_management_cog,
|
||||
mock_interaction,
|
||||
sample_team_data,
|
||||
mock_embed,
|
||||
):
|
||||
"""Test team command without abbreviation (user's own team)."""
|
||||
mock_get_by_owner.return_value = sample_team_data
|
||||
mock_team_summary.return_value = mock_embed
|
||||
|
||||
|
||||
async def mock_team_command(interaction, team_abbrev=None):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet! Use `/newteam` to create one.')
|
||||
await interaction.followup.send(
|
||||
"You don't have a team yet! Use `/newteam` to create one."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
embed = await mock_team_summary(team, interaction, include_roster=True)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
await mock_team_command(mock_interaction)
|
||||
|
||||
|
||||
mock_get_by_owner.assert_called_once_with(mock_interaction.user.id)
|
||||
mock_team_summary.assert_called_once()
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
async def test_team_command_user_no_team(self, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction):
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
async def test_team_command_user_no_team(
|
||||
self, mock_get_by_owner, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test team command when user has no team."""
|
||||
mock_get_by_owner.return_value = None
|
||||
|
||||
|
||||
async def mock_team_command(interaction, team_abbrev=None):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet! Use `/newteam` to create one.')
|
||||
await interaction.followup.send(
|
||||
"You don't have a team yet! Use `/newteam` to create one."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await mock_team_command(mock_interaction)
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('You don\'t have a team yet! Use `/newteam` to create one.')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
@patch('api_calls.db_patch')
|
||||
async def test_branding_command_success(self, mock_db_patch, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction,
|
||||
sample_team_data):
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"You don't have a team yet! Use `/newteam` to create one."
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
@patch("api_calls.db_patch")
|
||||
async def test_branding_command_success(
|
||||
self,
|
||||
mock_db_patch,
|
||||
mock_get_by_owner,
|
||||
team_management_cog,
|
||||
mock_interaction,
|
||||
sample_team_data,
|
||||
):
|
||||
"""Test successful team branding update."""
|
||||
mock_get_by_owner.return_value = sample_team_data
|
||||
mock_db_patch.return_value = {'success': True}
|
||||
|
||||
mock_db_patch.return_value = {"success": True}
|
||||
|
||||
async def mock_branding_command(interaction, new_color, new_logo_url=None):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
update_data = {'color': new_color}
|
||||
|
||||
update_data = {"color": new_color}
|
||||
if new_logo_url:
|
||||
update_data['logo'] = new_logo_url
|
||||
|
||||
update_data["logo"] = new_logo_url
|
||||
|
||||
response = await mock_db_patch(f'teams/{team["id"]}', data=update_data)
|
||||
|
||||
if response.get('success'):
|
||||
await interaction.followup.send(f'Successfully updated team branding!')
|
||||
|
||||
if response.get("success"):
|
||||
await interaction.followup.send(f"Successfully updated team branding!")
|
||||
else:
|
||||
await interaction.followup.send('Failed to update team branding.')
|
||||
|
||||
await mock_branding_command(mock_interaction, '#FF0000', 'https://example.com/logo.png')
|
||||
|
||||
await interaction.followup.send("Failed to update team branding.")
|
||||
|
||||
await mock_branding_command(
|
||||
mock_interaction, "#FF0000", "https://example.com/logo.png"
|
||||
)
|
||||
|
||||
mock_get_by_owner.assert_called_once()
|
||||
mock_db_patch.assert_called_once()
|
||||
mock_interaction.followup.send.assert_called_once_with('Successfully updated team branding!')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
async def test_branding_command_no_team(self, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction):
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"Successfully updated team branding!"
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
async def test_branding_command_no_team(
|
||||
self, mock_get_by_owner, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test team branding command when user has no team."""
|
||||
mock_get_by_owner.return_value = None
|
||||
|
||||
|
||||
async def mock_branding_command(interaction, new_color):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
await mock_branding_command(mock_interaction, '#FF0000')
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('You don\'t have a team yet!')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
@patch('api_calls.db_patch')
|
||||
async def test_branding_command_failure(self, mock_db_patch, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction,
|
||||
sample_team_data):
|
||||
|
||||
await mock_branding_command(mock_interaction, "#FF0000")
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"You don't have a team yet!"
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
@patch("api_calls.db_patch")
|
||||
async def test_branding_command_failure(
|
||||
self,
|
||||
mock_db_patch,
|
||||
mock_get_by_owner,
|
||||
team_management_cog,
|
||||
mock_interaction,
|
||||
sample_team_data,
|
||||
):
|
||||
"""Test team branding update failure."""
|
||||
mock_get_by_owner.return_value = sample_team_data
|
||||
mock_db_patch.return_value = {'success': False}
|
||||
|
||||
mock_db_patch.return_value = {"success": False}
|
||||
|
||||
async def mock_branding_command(interaction, new_color):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
update_data = {'color': new_color}
|
||||
|
||||
update_data = {"color": new_color}
|
||||
response = await mock_db_patch(f'teams/{team["id"]}', data=update_data)
|
||||
|
||||
if response.get('success'):
|
||||
await interaction.followup.send('Successfully updated team branding!')
|
||||
|
||||
if response.get("success"):
|
||||
await interaction.followup.send("Successfully updated team branding!")
|
||||
else:
|
||||
await interaction.followup.send('Failed to update team branding.')
|
||||
|
||||
await mock_branding_command(mock_interaction, '#FF0000')
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('Failed to update team branding.')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
@patch('pygsheets.authorize')
|
||||
@patch('helpers.get_roster_sheet')
|
||||
async def test_pullroster_command_success(self, mock_get_roster_sheet, mock_authorize,
|
||||
mock_get_by_owner, team_management_cog,
|
||||
mock_interaction, sample_team_data):
|
||||
await interaction.followup.send("Failed to update team branding.")
|
||||
|
||||
await mock_branding_command(mock_interaction, "#FF0000")
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"Failed to update team branding."
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
@patch("pygsheets.authorize")
|
||||
@patch("helpers.get_roster_sheet")
|
||||
async def test_pullroster_command_success(
|
||||
self,
|
||||
mock_get_roster_sheet,
|
||||
mock_authorize,
|
||||
mock_get_by_owner,
|
||||
team_management_cog,
|
||||
mock_interaction,
|
||||
sample_team_data,
|
||||
):
|
||||
"""Test successful roster pull from Google Sheets."""
|
||||
mock_get_by_owner.return_value = sample_team_data
|
||||
mock_gc = Mock()
|
||||
mock_authorize.return_value = mock_gc
|
||||
mock_get_roster_sheet.return_value = Mock()
|
||||
|
||||
|
||||
async def mock_pullroster_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
if not team.get('gsheet'):
|
||||
await interaction.followup.send('No Google Sheet configured for your team.')
|
||||
|
||||
if not team.get("gsheet"):
|
||||
await interaction.followup.send(
|
||||
"No Google Sheet configured for your team."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
gc = mock_authorize()
|
||||
roster_data = await mock_get_roster_sheet(gc, team['gsheet'])
|
||||
await interaction.followup.send('Successfully pulled roster from Google Sheets!')
|
||||
roster_data = await mock_get_roster_sheet(gc, team["gsheet"])
|
||||
await interaction.followup.send(
|
||||
"Successfully pulled roster from Google Sheets!"
|
||||
)
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f'Error pulling roster: {str(e)}')
|
||||
|
||||
await interaction.followup.send(f"Error pulling roster: {str(e)}")
|
||||
|
||||
await mock_pullroster_command(mock_interaction)
|
||||
|
||||
|
||||
mock_get_by_owner.assert_called_once()
|
||||
mock_authorize.assert_called_once()
|
||||
mock_get_roster_sheet.assert_called_once()
|
||||
mock_interaction.followup.send.assert_called_once_with('Successfully pulled roster from Google Sheets!')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
async def test_pullroster_command_no_team(self, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction):
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"Successfully pulled roster from Google Sheets!"
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
async def test_pullroster_command_no_team(
|
||||
self, mock_get_by_owner, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test roster pull when user has no team."""
|
||||
mock_get_by_owner.return_value = None
|
||||
|
||||
|
||||
async def mock_pullroster_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
|
||||
await mock_pullroster_command(mock_interaction)
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('You don\'t have a team yet!')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
async def test_pullroster_command_no_sheet(self, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction):
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"You don't have a team yet!"
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
async def test_pullroster_command_no_sheet(
|
||||
self, mock_get_by_owner, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test roster pull when team has no Google Sheet configured."""
|
||||
team_data_no_sheet = {**sample_team_data, 'gsheet': None}
|
||||
team_data_no_sheet = {**sample_team_data, "gsheet": None}
|
||||
mock_get_by_owner.return_value = team_data_no_sheet
|
||||
|
||||
|
||||
async def mock_pullroster_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
if not team.get('gsheet'):
|
||||
await interaction.followup.send('No Google Sheet configured for your team.')
|
||||
|
||||
if not team.get("gsheet"):
|
||||
await interaction.followup.send(
|
||||
"No Google Sheet configured for your team."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await mock_pullroster_command(mock_interaction)
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('No Google Sheet configured for your team.')
|
||||
|
||||
@patch('helpers.get_team_by_owner')
|
||||
@patch('pygsheets.authorize')
|
||||
async def test_pullroster_command_error(self, mock_authorize, mock_get_by_owner,
|
||||
team_management_cog, mock_interaction,
|
||||
sample_team_data):
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"No Google Sheet configured for your team."
|
||||
)
|
||||
|
||||
@patch("helpers.get_team_by_owner")
|
||||
@patch("pygsheets.authorize")
|
||||
async def test_pullroster_command_error(
|
||||
self,
|
||||
mock_authorize,
|
||||
mock_get_by_owner,
|
||||
team_management_cog,
|
||||
mock_interaction,
|
||||
sample_team_data,
|
||||
):
|
||||
"""Test roster pull error handling."""
|
||||
mock_get_by_owner.return_value = sample_team_data
|
||||
mock_authorize.side_effect = Exception("Google Sheets API Error")
|
||||
|
||||
|
||||
async def mock_pullroster_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
|
||||
team = await mock_get_by_owner(interaction.user.id)
|
||||
if not team:
|
||||
await interaction.followup.send('You don\'t have a team yet!')
|
||||
await interaction.followup.send("You don't have a team yet!")
|
||||
return
|
||||
|
||||
if not team.get('gsheet'):
|
||||
await interaction.followup.send('No Google Sheet configured for your team.')
|
||||
|
||||
if not team.get("gsheet"):
|
||||
await interaction.followup.send(
|
||||
"No Google Sheet configured for your team."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
gc = mock_authorize()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f'Error pulling roster: {str(e)}')
|
||||
|
||||
await interaction.followup.send(f"Error pulling roster: {str(e)}")
|
||||
|
||||
await mock_pullroster_command(mock_interaction)
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('Error pulling roster: Google Sheets API Error')
|
||||
|
||||
@patch('api_calls.db_get')
|
||||
async def test_ai_teams_command_success(self, mock_db_get, team_management_cog,
|
||||
mock_interaction, mock_embed):
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"Error pulling roster: Google Sheets API Error"
|
||||
)
|
||||
|
||||
@patch("api_calls.db_get")
|
||||
async def test_ai_teams_command_success(
|
||||
self, mock_db_get, team_management_cog, mock_interaction, mock_embed
|
||||
):
|
||||
"""Test successful AI teams listing."""
|
||||
ai_teams_data = {
|
||||
'count': 2,
|
||||
'teams': [
|
||||
{'id': 1, 'abbrev': 'AI1', 'sname': 'AI Team 1', 'is_ai': True},
|
||||
{'id': 2, 'abbrev': 'AI2', 'sname': 'AI Team 2', 'is_ai': True}
|
||||
]
|
||||
"count": 2,
|
||||
"teams": [
|
||||
{"id": 1, "abbrev": "AI1", "sname": "AI Team 1", "is_ai": True},
|
||||
{"id": 2, "abbrev": "AI2", "sname": "AI Team 2", "is_ai": True},
|
||||
],
|
||||
}
|
||||
mock_db_get.return_value = ai_teams_data
|
||||
|
||||
|
||||
async def mock_ai_teams_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
teams_response = await mock_db_get('teams', params=[('is_ai', 'true')])
|
||||
|
||||
if not teams_response or teams_response['count'] == 0:
|
||||
await interaction.followup.send('No AI teams found.')
|
||||
|
||||
teams_response = await mock_db_get("teams", params=[("is_ai", "true")])
|
||||
|
||||
if not teams_response or teams_response["count"] == 0:
|
||||
await interaction.followup.send("No AI teams found.")
|
||||
return
|
||||
|
||||
ai_teams = teams_response['teams']
|
||||
team_list = '\n'.join([f"{team['abbrev']} - {team['sname']}" for team in ai_teams])
|
||||
|
||||
|
||||
ai_teams = teams_response["teams"]
|
||||
team_list = "\n".join(
|
||||
[f"{team['abbrev']} - {team['sname']}" for team in ai_teams]
|
||||
)
|
||||
|
||||
embed = mock_embed
|
||||
embed.title = f'AI Teams ({len(ai_teams)})'
|
||||
embed.title = f"AI Teams ({len(ai_teams)})"
|
||||
embed.description = team_list
|
||||
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
await mock_ai_teams_command(mock_interaction)
|
||||
|
||||
mock_db_get.assert_called_once_with('teams', params=[('is_ai', 'true')])
|
||||
|
||||
mock_db_get.assert_called_once_with("teams", params=[("is_ai", "true")])
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
|
||||
@patch('api_calls.db_get')
|
||||
async def test_ai_teams_command_no_teams(self, mock_db_get, team_management_cog,
|
||||
mock_interaction):
|
||||
|
||||
@patch("api_calls.db_get")
|
||||
async def test_ai_teams_command_no_teams(
|
||||
self, mock_db_get, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test AI teams command when no AI teams exist."""
|
||||
mock_db_get.return_value = {'count': 0, 'teams': []}
|
||||
|
||||
mock_db_get.return_value = {"count": 0, "teams": []}
|
||||
|
||||
async def mock_ai_teams_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
teams_response = await mock_db_get('teams', params=[('is_ai', 'true')])
|
||||
|
||||
if not teams_response or teams_response['count'] == 0:
|
||||
await interaction.followup.send('No AI teams found.')
|
||||
|
||||
teams_response = await mock_db_get("teams", params=[("is_ai", "true")])
|
||||
|
||||
if not teams_response or teams_response["count"] == 0:
|
||||
await interaction.followup.send("No AI teams found.")
|
||||
return
|
||||
|
||||
|
||||
await mock_ai_teams_command(mock_interaction)
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('No AI teams found.')
|
||||
|
||||
@patch('api_calls.db_get')
|
||||
async def test_ai_teams_command_api_error(self, mock_db_get, team_management_cog,
|
||||
mock_interaction):
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with("No AI teams found.")
|
||||
|
||||
@patch("api_calls.db_get")
|
||||
async def test_ai_teams_command_api_error(
|
||||
self, mock_db_get, team_management_cog, mock_interaction
|
||||
):
|
||||
"""Test AI teams command API error handling."""
|
||||
mock_db_get.return_value = None
|
||||
|
||||
|
||||
async def mock_ai_teams_command(interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
teams_response = await mock_db_get('teams', params=[('is_ai', 'true')])
|
||||
|
||||
|
||||
teams_response = await mock_db_get("teams", params=[("is_ai", "true")])
|
||||
|
||||
if not teams_response:
|
||||
await interaction.followup.send('Error retrieving AI teams.')
|
||||
await interaction.followup.send("Error retrieving AI teams.")
|
||||
return
|
||||
|
||||
|
||||
await mock_ai_teams_command(mock_interaction)
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with('Error retrieving AI teams.')
|
||||
|
||||
|
||||
mock_interaction.followup.send.assert_called_once_with(
|
||||
"Error retrieving AI teams."
|
||||
)
|
||||
|
||||
def test_color_validation(self, team_management_cog):
|
||||
"""Test color format validation for branding command."""
|
||||
valid_colors = ['#FF0000', '#00FF00', '#0000FF', 'FF0000', '123ABC']
|
||||
invalid_colors = ['invalid', '#GGGGGG', '12345', '#1234567']
|
||||
|
||||
valid_colors = ["#FF0000", "#00FF00", "#0000FF", "FF0000", "123ABC"]
|
||||
invalid_colors = ["invalid", "#GGGGGG", "12345", "#1234567"]
|
||||
|
||||
def is_valid_color(color):
|
||||
# Basic hex color validation
|
||||
if color.startswith('#'):
|
||||
if color.startswith("#"):
|
||||
color = color[1:]
|
||||
return len(color) == 6 and all(c in '0123456789ABCDEFabcdef' for c in color)
|
||||
|
||||
return len(color) == 6 and all(c in "0123456789ABCDEFabcdef" for c in color)
|
||||
|
||||
for color in valid_colors:
|
||||
assert is_valid_color(color), f"Color {color} should be valid"
|
||||
|
||||
|
||||
for color in invalid_colors:
|
||||
assert not is_valid_color(color), f"Color {color} should be invalid"
|
||||
|
||||
|
||||
def test_url_validation(self, team_management_cog):
|
||||
"""Test URL validation for logo updates."""
|
||||
valid_urls = [
|
||||
'https://example.com/image.png',
|
||||
'https://cdn.example.com/logo.jpg',
|
||||
'http://test.com/image.gif'
|
||||
"https://example.com/image.png",
|
||||
"https://cdn.example.com/logo.jpg",
|
||||
"http://test.com/image.gif",
|
||||
]
|
||||
invalid_urls = [
|
||||
'not_a_url',
|
||||
'ftp://example.com/file.txt',
|
||||
'javascript:alert(1)'
|
||||
"not_a_url",
|
||||
"ftp://example.com/file.txt",
|
||||
"javascript:alert(1)",
|
||||
]
|
||||
|
||||
|
||||
def is_valid_url(url):
|
||||
return url.startswith(('http://', 'https://'))
|
||||
|
||||
return url.startswith(("http://", "https://"))
|
||||
|
||||
for url in valid_urls:
|
||||
assert is_valid_url(url), f"URL {url} should be valid"
|
||||
|
||||
|
||||
for url in invalid_urls:
|
||||
assert not is_valid_url(url), f"URL {url} should be invalid"
|
||||
|
||||
@patch('helpers.get_rosters')
|
||||
async def test_roster_integration(self, mock_get_rosters, team_management_cog,
|
||||
sample_team_data):
|
||||
|
||||
@patch("helpers.get_rosters")
|
||||
async def test_roster_integration(
|
||||
self, mock_get_rosters, team_management_cog, sample_team_data
|
||||
):
|
||||
"""Test roster data integration with team display."""
|
||||
roster_data = {
|
||||
'active_roster': [
|
||||
{'card_id': 1, 'player_name': 'Player 1', 'position': 'C'},
|
||||
{'card_id': 2, 'player_name': 'Player 2', 'position': '1B'}
|
||||
"active_roster": [
|
||||
{"card_id": 1, "player_name": "Player 1", "position": "C"},
|
||||
{"card_id": 2, "player_name": "Player 2", "position": "1B"},
|
||||
],
|
||||
'bench': [
|
||||
{'card_id': 3, 'player_name': 'Player 3', 'position': 'OF'}
|
||||
]
|
||||
"bench": [{"card_id": 3, "player_name": "Player 3", "position": "OF"}],
|
||||
}
|
||||
mock_get_rosters.return_value = roster_data
|
||||
|
||||
rosters = await mock_get_rosters(sample_team_data['id'])
|
||||
|
||||
|
||||
rosters = await mock_get_rosters(sample_team_data["id"])
|
||||
|
||||
assert rosters is not None
|
||||
assert 'active_roster' in rosters
|
||||
assert 'bench' in rosters
|
||||
assert len(rosters['active_roster']) == 2
|
||||
assert len(rosters['bench']) == 1
|
||||
|
||||
def test_team_embed_formatting(self, team_management_cog, sample_team_data, mock_embed):
|
||||
assert "active_roster" in rosters
|
||||
assert "bench" in rosters
|
||||
assert len(rosters["active_roster"]) == 2
|
||||
assert len(rosters["bench"]) == 1
|
||||
|
||||
def test_team_embed_formatting(
|
||||
self, team_management_cog, sample_team_data, mock_embed
|
||||
):
|
||||
"""Test proper formatting of team summary embeds."""
|
||||
|
||||
# Mock the team summary embed creation
|
||||
def create_team_summary_embed(team, include_roster=False):
|
||||
embed = mock_embed
|
||||
embed.title = f"{team['abbrev']} - {team['sname']}"
|
||||
embed.add_field(name="GM", value=team['gmname'], inline=True)
|
||||
embed.add_field(name="GM", value=team["gmname"], inline=True)
|
||||
embed.add_field(name="Wallet", value=f"${team['wallet']}", inline=True)
|
||||
embed.add_field(name="Team Value", value=f"${team['team_value']}", inline=True)
|
||||
|
||||
if team['color']:
|
||||
embed.color = int(team['color'], 16)
|
||||
|
||||
embed.add_field(
|
||||
name="Team Value", value=f"${team['team_value']}", inline=True
|
||||
)
|
||||
|
||||
if team["color"]:
|
||||
embed.color = int(team["color"], 16)
|
||||
|
||||
if include_roster:
|
||||
embed.add_field(name="Roster", value="Active roster info...", inline=False)
|
||||
|
||||
embed.add_field(
|
||||
name="Roster", value="Active roster info...", inline=False
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
embed = create_team_summary_embed(sample_team_data, include_roster=True)
|
||||
|
||||
assert embed.title == f"{sample_team_data['abbrev']} - {sample_team_data['sname']}"
|
||||
|
||||
assert (
|
||||
embed.title == f"{sample_team_data['abbrev']} - {sample_team_data['sname']}"
|
||||
)
|
||||
embed.add_field.assert_called()
|
||||
|
||||
|
||||
def test_permission_checks(self, team_management_cog, mock_interaction):
|
||||
"""Test role and channel permission checking."""
|
||||
# Test role check
|
||||
mock_member_with_role = Mock()
|
||||
mock_member_with_role.roles = [Mock(name='Paper Dynasty')]
|
||||
mock_member_with_role.roles = [Mock(name="Paper Dynasty")]
|
||||
mock_interaction.user = mock_member_with_role
|
||||
|
||||
|
||||
# Test channel check
|
||||
with patch('helpers.legal_channel') as mock_legal_check:
|
||||
with patch("helpers.legal_channel") as mock_legal_check:
|
||||
mock_legal_check.return_value = True
|
||||
result = mock_legal_check(mock_interaction.channel)
|
||||
assert result is True
|
||||
|
||||
@patch('logging.getLogger')
|
||||
async def test_error_handling_and_logging(self, mock_logger, team_management_cog):
|
||||
"""Test error handling and logging across team management operations."""
|
||||
mock_logger_instance = Mock()
|
||||
mock_logger.return_value = mock_logger_instance
|
||||
|
||||
# Test API timeout error
|
||||
with patch('api_calls.db_get') as mock_db_get:
|
||||
mock_db_get.side_effect = asyncio.TimeoutError("Request timeout")
|
||||
|
||||
try:
|
||||
await mock_db_get('teams')
|
||||
except asyncio.TimeoutError:
|
||||
# In actual implementation, this would be caught and logged
|
||||
pass
|
||||
|
||||
# Test Google Sheets authentication error
|
||||
with patch('pygsheets.authorize') as mock_authorize:
|
||||
mock_authorize.side_effect = Exception("Auth failed")
|
||||
|
||||
try:
|
||||
mock_authorize()
|
||||
except Exception:
|
||||
# In actual implementation, this would be caught and logged
|
||||
pass
|
||||
|
||||
async def test_error_handling_and_logging(self, team_management_cog, mock_context):
|
||||
"""Test that pull_roster_command sends an error message when get_rosters raises.
|
||||
|
||||
Invokes the actual cog method callback so the test fails if the method body is
|
||||
removed or the exception-handling branch is broken.
|
||||
"""
|
||||
cmd = getattr(team_management_cog, "pull_roster_command", None)
|
||||
if cmd is None or not hasattr(cmd, "callback"):
|
||||
pytest.skip(
|
||||
"TeamManagement cog not importable; cannot test callback directly"
|
||||
)
|
||||
|
||||
team_with_sheet = {
|
||||
"id": 1,
|
||||
"abbrev": "TST",
|
||||
"sname": "Test",
|
||||
"gsheet": "valid-sheet-id",
|
||||
}
|
||||
with patch(
|
||||
"cogs.players_new.team_management.get_context_user"
|
||||
) as mock_get_ctx_user, patch(
|
||||
"cogs.players_new.team_management.get_team_by_owner",
|
||||
new=AsyncMock(return_value=team_with_sheet),
|
||||
), patch(
|
||||
"cogs.players_new.team_management.get_rosters",
|
||||
side_effect=Exception("Connection error"),
|
||||
):
|
||||
mock_get_ctx_user.return_value = mock_context.author
|
||||
await cmd.callback(team_management_cog, mock_context)
|
||||
|
||||
mock_context.send.assert_called_once_with(
|
||||
"Could not retrieve rosters from your sheet."
|
||||
)
|
||||
|
||||
0
tests/scouting/__init__.py
Normal file
0
tests/scouting/__init__.py
Normal file
170
tests/scouting/conftest.py
Normal file
170
tests/scouting/conftest.py
Normal file
@ -0,0 +1,170 @@
|
||||
"""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,
|
||||
description="2023",
|
||||
image=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",
|
||||
"description": description,
|
||||
"image": image or f"https://example.com/cards/{player_id}/battingcard.png",
|
||||
}
|
||||
|
||||
|
||||
def _make_card(card_id, player, pack_id=100):
|
||||
"""Wrap a player dict inside a card dict (as returned by the cards API)."""
|
||||
return {"id": card_id, "player": player, "pack": {"id": pack_id}}
|
||||
|
||||
|
||||
@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)
|
||||
1015
tests/scouting/test_scout_view.py
Normal file
1015
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