diff --git a/Dockerfile b/Dockerfile index 6be2cdd..80cbedb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.12.13-slim WORKDIR /usr/src/app diff --git a/cogs/economy_new/scouting.py b/cogs/economy_new/scouting.py index 118927a..53d2e8d 100644 --- a/cogs/economy_new/scouting.py +++ b/cogs/economy_new/scouting.py @@ -10,11 +10,14 @@ 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.scouting import ( + SCOUT_TOKEN_COST, + 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") @@ -54,7 +57,10 @@ class Scouting(commands.Cog): ) if tokens_remaining == 0: - embed.description += "\n\nYou've used all your tokens! Check back tomorrow." + embed.description += ( + f"\n\nYou've used all your free tokens! " + f"You can still scout by purchasing a token for **{SCOUT_TOKEN_COST}₼**." + ) await interaction.followup.send(embed=embed, ephemeral=True) diff --git a/cogs/players_new/__init__.py b/cogs/players_new/__init__.py index 736f370..c3fdd76 100644 --- a/cogs/players_new/__init__.py +++ b/cogs/players_new/__init__.py @@ -5,11 +5,7 @@ from .shared_utils import get_ai_records, get_record_embed, get_record_embed_leg import logging from discord.ext import commands -__all__ = [ - 'get_ai_records', - 'get_record_embed', - 'get_record_embed_legacy' -] +__all__ = ["get_ai_records", "get_record_embed", "get_record_embed_legacy"] async def setup(bot): @@ -24,12 +20,14 @@ async def setup(bot): from .standings_records import StandingsRecords from .team_management import TeamManagement from .utility_commands import UtilityCommands - + from .evolution import Evolution + await bot.add_cog(Gauntlet(bot)) await bot.add_cog(Paperdex(bot)) await bot.add_cog(PlayerLookup(bot)) await bot.add_cog(StandingsRecords(bot)) await bot.add_cog(TeamManagement(bot)) await bot.add_cog(UtilityCommands(bot)) - - logging.getLogger('discord_app').info('All player cogs loaded successfully') \ No newline at end of file + await bot.add_cog(Evolution(bot)) + + logging.getLogger("discord_app").info("All player cogs loaded successfully") diff --git a/cogs/players_new/evolution.py b/cogs/players_new/evolution.py new file mode 100644 index 0000000..902fd24 --- /dev/null +++ b/cogs/players_new/evolution.py @@ -0,0 +1,206 @@ +# Evolution Status Module +# Displays evolution tier progress for a team's cards + +from discord.ext import commands +from discord import app_commands +import discord +from typing import Optional +import logging + +from api_calls import db_get +from helpers import get_team_by_owner, is_ephemeral_channel + +logger = logging.getLogger("discord_app") + +# Tier display names +TIER_NAMES = { + 0: "Unranked", + 1: "Initiate", + 2: "Rising", + 3: "Ascendant", + 4: "Evolved", +} + +# Formula shorthands by card_type +FORMULA_SHORTHANDS = { + "batter": "PA+TB×2", + "sp": "IP+K", + "rp": "IP+K", +} + + +def render_progress_bar( + current_value: float, next_threshold: float | None, width: int = 10 +) -> str: + """Render a text progress bar. + + Args: + current_value: Current formula value. + next_threshold: Threshold for the next tier. None if fully evolved. + width: Number of characters in the bar. + + Returns: + A string like '[========--] 120/149' or '[==========] FULLY EVOLVED'. + """ + if next_threshold is None or next_threshold <= 0: + return f"[{'=' * width}] FULLY EVOLVED" + + ratio = min(current_value / next_threshold, 1.0) + filled = round(ratio * width) + empty = width - filled + bar = f"[{'=' * filled}{'-' * empty}]" + return f"{bar} {int(current_value)}/{int(next_threshold)}" + + +def format_evo_entry(state: dict) -> str: + """Format a single evolution card state into a display line. + + Args: + state: Card state dict from the API with nested track info. + + Returns: + Formatted string like 'Mike Trout [========--] 120/149 (PA+TB×2) T1 → T2' + """ + track = state.get("track", {}) + card_type = track.get("card_type", "batter") + formula = FORMULA_SHORTHANDS.get(card_type, "???") + current_tier = state.get("current_tier", 0) + current_value = state.get("current_value", 0.0) + next_threshold = state.get("next_threshold") + fully_evolved = state.get("fully_evolved", False) + + bar = render_progress_bar(current_value, next_threshold) + + if fully_evolved: + tier_label = f"T4 — {TIER_NAMES[4]}" + else: + next_tier = current_tier + 1 + tier_label = ( + f"{TIER_NAMES.get(current_tier, '?')} → {TIER_NAMES.get(next_tier, '?')}" + ) + + return f"{bar} ({formula}) {tier_label}" + + +def is_close_to_tierup(state: dict, threshold_pct: float = 0.80) -> bool: + """Check if a card is close to its next tier-up. + + Args: + state: Card state dict from the API. + threshold_pct: Fraction of next_threshold that counts as "close". + + Returns: + True if current_value >= threshold_pct * next_threshold. + """ + next_threshold = state.get("next_threshold") + if next_threshold is None or next_threshold <= 0: + return False + current_value = state.get("current_value", 0.0) + return current_value >= threshold_pct * next_threshold + + +class Evolution(commands.Cog): + """Evolution tier progress for Paper Dynasty cards.""" + + def __init__(self, bot): + self.bot = bot + + evo_group = app_commands.Group(name="evo", description="Evolution commands") + + @evo_group.command(name="status", description="View your team's evolution progress") + @app_commands.describe( + type="Filter by card type (batter, sp, rp)", + tier="Filter by minimum tier (0-4)", + progress="Show only cards close to tier-up (type 'close')", + page="Page number (default: 1)", + ) + async def evo_status( + self, + interaction: discord.Interaction, + type: Optional[str] = None, + tier: Optional[int] = None, + progress: Optional[str] = None, + page: int = 1, + ): + await interaction.response.defer( + ephemeral=is_ephemeral_channel(interaction.channel) + ) + + # Look up the user's team + team = await get_team_by_owner(interaction.user.id) + if not team: + await interaction.followup.send( + "You don't have a team registered. Use `/register` first.", + ephemeral=True, + ) + return + + team_id = team.get("team_id") or team.get("id") + + # Build query params + params = [("page", page), ("per_page", 10)] + if type: + params.append(("card_type", type)) + if tier is not None: + params.append(("tier", tier)) + + try: + result = await db_get( + f"teams/{team_id}/evolutions", + params=params, + none_okay=True, + ) + except Exception: + logger.warning( + f"Failed to fetch evolution data for team {team_id}", + exc_info=True, + ) + await interaction.followup.send( + "Could not fetch evolution data. Please try again later.", + ephemeral=True, + ) + return + + if not result or not result.get("items"): + await interaction.followup.send( + "No evolution cards found for your team.", + ephemeral=True, + ) + return + + items = result["items"] + total_count = result.get("count", len(items)) + + # Apply "close" filter client-side + if progress and progress.lower() == "close": + items = [s for s in items if is_close_to_tierup(s)] + if not items: + await interaction.followup.send( + "No cards are close to a tier-up right now.", + ephemeral=True, + ) + return + + # Build embed + embed = discord.Embed( + title=f"Evolution Progress — {team.get('lname', 'Your Team')}", + color=discord.Color.purple(), + ) + + lines = [] + for state in items: + # Try to get player name from the state + player_name = state.get( + "player_name", f"Player #{state.get('player_id', '?')}" + ) + entry = format_evo_entry(state) + lines.append(f"**{player_name}**\n{entry}") + + embed.description = "\n\n".join(lines) if lines else "No evolution data." + + # Pagination footer + per_page = 10 + total_pages = max(1, (total_count + per_page - 1) // per_page) + embed.set_footer(text=f"Page {page}/{total_pages} • {total_count} total cards") + + await interaction.followup.send(embed=embed) diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 55f2532..4b782e9 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -4242,6 +4242,24 @@ async def get_game_summary_embed( return game_embed +async def notify_tier_completion(channel: discord.TextChannel, tier_up: dict) -> None: + """Stub for WP-14: log evolution tier-up events. + + WP-14 will replace this with a full Discord embed notification. For now we + only log the event so that the WP-13 hook has a callable target and the + tier-up data is visible in the application log. + + Args: + channel: The Discord channel where the game was played. + tier_up: Dict from the evolution API, expected to contain at minimum + 'player_id', 'old_tier', and 'new_tier' keys. + """ + logger.info( + f"[WP-14 stub] notify_tier_completion called for channel={channel.id if channel else 'N/A'} " + f"tier_up={tier_up}" + ) + + async def complete_game( session: Session, interaction: discord.Interaction, @@ -4345,6 +4363,26 @@ async def complete_game( await roll_back(db_game["id"], plays=True, decisions=True) log_exception(e, msg="Error while posting game rewards") + # Post-game evolution processing (non-blocking) + # WP-13: update season stats then evaluate evolution milestones for all + # participating players. Wrapped in try/except so any failure here is + # non-fatal — the game is already saved and evolution will catch up on the + # next evaluate call. + try: + await db_post(f"season-stats/update-game/{db_game['id']}") + evo_result = await db_post(f"evolution/evaluate-game/{db_game['id']}") + if evo_result and evo_result.get("tier_ups"): + for tier_up in evo_result["tier_ups"]: + # WP-14 will implement full Discord notification; stub for now + logger.info( + f"Evolution tier-up for player {tier_up.get('player_id')}: " + f"{tier_up.get('old_tier')} -> {tier_up.get('new_tier')} " + f"(game {db_game['id']})" + ) + await notify_tier_completion(interaction.channel, tier_up) + except Exception as e: + logger.warning(f"Post-game evolution processing failed (non-fatal): {e}") + session.delete(this_play) session.commit() diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 7dcecff..ffeff87 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -14,6 +14,7 @@ import discord from api_calls import db_get, db_post from helpers.main import get_team_by_owner, get_card_embeds from helpers.scouting import ( + SCOUT_TOKEN_COST, SCOUT_TOKENS_PER_DAY, build_scout_embed, get_scout_tokens_used, @@ -61,7 +62,7 @@ class ScoutView(discord.ui.View): # Users currently being processed (prevent double-click race) self.processing_users: set[int] = set() - for i, card in enumerate(cards): + for i, card in enumerate(cards[-25:]): button = ScoutButton( card=card, position=i, @@ -87,7 +88,7 @@ class ScoutView(discord.ui.View): ) try: - await self.message.edit(embed=embed, view=self) + await self.message.edit(embed=embed) except Exception as e: logger.error(f"Failed to update scout message: {e}") @@ -117,7 +118,7 @@ class ScoutButton(discord.ui.Button): super().__init__( label=f"Card {position + 1}", style=discord.ButtonStyle.secondary, - row=0, + row=position // 5, ) self.card = card self.position = position @@ -167,11 +168,27 @@ class ScoutButton(discord.ui.Button): 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, + # Offer to buy an extra scout token + buy_view = BuyScoutTokenView( + scouter_team=scouter_team, + responder_id=interaction.user.id, ) - return + buy_msg = await interaction.followup.send( + f"You're out of scout tokens for today! " + f"You can buy one for **{SCOUT_TOKEN_COST}₼** " + f"(wallet: {scouter_team['wallet']}₼).", + view=buy_view, + ephemeral=True, + wait=True, + ) + buy_view.message = buy_msg + await buy_view.wait() + + if not buy_view.value: + return + + # Refresh team data after purchase + scouter_team = buy_view.scouter_team # Record the claim in the database try: @@ -272,3 +289,86 @@ class ScoutButton(discord.ui.Button): pass finally: view.processing_users.discard(interaction.user.id) + + +class BuyScoutTokenView(discord.ui.View): + """Ephemeral confirmation view for purchasing an extra scout token.""" + + def __init__(self, scouter_team: dict, responder_id: int): + super().__init__(timeout=30.0) + self.scouter_team = scouter_team + self.responder_id = responder_id + self.value = False + self.message: discord.Message | None = None + + if scouter_team["wallet"] < SCOUT_TOKEN_COST: + self.buy_button.disabled = True + self.buy_button.label = ( + f"Not enough ₼ ({scouter_team['wallet']}/{SCOUT_TOKEN_COST})" + ) + + async def on_timeout(self): + """Disable buttons when the buy window expires.""" + for item in self.children: + item.disabled = True + if self.message: + try: + await self.message.edit(view=self) + except Exception: + pass + + @discord.ui.button( + label=f"Buy Scout Token ({SCOUT_TOKEN_COST}₼)", + style=discord.ButtonStyle.green, + ) + async def buy_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.id != self.responder_id: + return + + # Re-fetch team to get current wallet (prevent stale data) + team = await get_team_by_owner(interaction.user.id) + if not team or team["wallet"] < SCOUT_TOKEN_COST: + await interaction.response.edit_message( + content="You don't have enough ₼ for a scout token.", + view=None, + ) + self.stop() + return + + # Deduct currency + new_wallet = team["wallet"] - SCOUT_TOKEN_COST + try: + await db_post(f'teams/{team["id"]}/money/-{SCOUT_TOKEN_COST}') + except Exception as e: + logger.error(f"Failed to deduct scout token cost: {e}") + await interaction.response.edit_message( + content="Something went wrong processing your purchase. Try again!", + view=None, + ) + self.stop() + return + + self.scouter_team = team + self.scouter_team["wallet"] = new_wallet + self.value = True + + await interaction.response.edit_message( + content=f"Scout token purchased for {SCOUT_TOKEN_COST}₼! Scouting your card...", + view=None, + ) + self.stop() + + @discord.ui.button(label="No thanks", style=discord.ButtonStyle.grey) + async def cancel_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + if interaction.user.id != self.responder_id: + return + + await interaction.response.edit_message( + content="Saving that money. Smart.", + view=None, + ) + self.stop() diff --git a/helpers/evolution_notifs.py b/helpers/evolution_notifs.py new file mode 100644 index 0000000..a86c5b9 --- /dev/null +++ b/helpers/evolution_notifs.py @@ -0,0 +1,106 @@ +""" +Evolution Tier Completion Notifications + +Builds and sends Discord embeds when a player completes an evolution tier +during post-game evaluation. Each tier-up event gets its own embed. + +Notification failures are non-fatal: the send is wrapped in try/except so +a Discord API hiccup never disrupts game flow. +""" + +import logging + +import discord + +logger = logging.getLogger("discord_app") + +# Human-readable display names for each tier number. +TIER_NAMES = { + 0: "Unranked", + 1: "Initiate", + 2: "Rising", + 3: "Ascendant", + 4: "Evolved", +} + +# Tier-specific embed colors. +TIER_COLORS = { + 1: 0x2ECC71, # green + 2: 0xF1C40F, # gold + 3: 0x9B59B6, # purple + 4: 0x1ABC9C, # teal (fully evolved) +} + +FOOTER_TEXT = "Paper Dynasty Evolution" + + +def build_tier_up_embed(tier_up: dict) -> discord.Embed: + """Build a Discord embed for a tier-up event. + + Parameters + ---------- + tier_up: + Dict with keys: player_name, old_tier, new_tier, current_value, track_name. + + Returns + ------- + discord.Embed + A fully configured embed ready to send to a channel. + """ + player_name: str = tier_up["player_name"] + new_tier: int = tier_up["new_tier"] + track_name: str = tier_up["track_name"] + + tier_name = TIER_NAMES.get(new_tier, f"Tier {new_tier}") + color = TIER_COLORS.get(new_tier, 0x2ECC71) + + if new_tier >= 4: + # Fully evolved — special title and description. + embed = discord.Embed( + title="FULLY EVOLVED!", + description=( + f"**{player_name}** has reached maximum evolution on the **{track_name}** track" + ), + color=color, + ) + embed.add_field( + name="Rating Boosts", + value="Rating boosts coming in a future update!", + inline=False, + ) + else: + embed = discord.Embed( + title="Evolution Tier Up!", + description=( + f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track" + ), + color=color, + ) + + embed.set_footer(text=FOOTER_TEXT) + return embed + + +async def notify_tier_completion(channel, tier_up: dict) -> None: + """Send a tier-up notification embed to the given channel. + + Non-fatal: any exception during send is caught and logged so that a + Discord API failure never interrupts game evaluation. + + Parameters + ---------- + channel: + A discord.TextChannel (or any object with an async ``send`` method). + tier_up: + Dict with keys: player_name, old_tier, new_tier, current_value, track_name. + """ + try: + embed = build_tier_up_embed(tier_up) + await channel.send(embed=embed) + except Exception as exc: + logger.error( + "Failed to send tier-up notification for %s (tier %s): %s", + tier_up.get("player_name", "unknown"), + tier_up.get("new_tier"), + exc, + ) diff --git a/helpers/main.py b/helpers/main.py index 0b989a5..b879ea1 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -122,8 +122,24 @@ async def share_channel(channel, user, read_only=False): async def get_card_embeds(card, include_stats=False) -> list: + # WP-12: fetch evolution state and build tier badge prefix. + # Non-blocking — any failure falls back to no badge so card display is + # never broken by an unavailable or slow evolution API. + tier_badge = "" + try: + evo_state = await db_get(f"evolution/cards/{card['id']}", none_okay=True) + if evo_state and evo_state.get("current_tier", 0) > 0: + tier = evo_state["current_tier"] + tier_badge = f"[{'EVO' if tier >= 4 else f'T{tier}'}] " + except Exception: + logging.warning( + f"Could not fetch evolution state for card {card.get('id')}; " + "displaying without tier badge.", + exc_info=True, + ) + embed = discord.Embed( - title=f"{card['player']['p_name']}", + title=f"{tier_badge}{card['player']['p_name']}", color=int(card["player"]["rarity"]["color"], 16), ) # embed.description = card['team']['lname'] diff --git a/helpers/scouting.py b/helpers/scouting.py index 6c926bb..22d95d9 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -20,6 +20,7 @@ from helpers.constants import IMAGES, PD_SEASON logger = logging.getLogger("discord_app") SCOUT_TOKENS_PER_DAY = 2 +SCOUT_TOKEN_COST = 200 # Currency cost to buy an extra scout token 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()} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..41423cc --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.0.2 +pytest-asyncio==1.3.0 diff --git a/requirements.txt b/requirements.txt index dd3e4dd..82b0822 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,13 @@ -discord.py -pygsheets -pydantic -gsheets -bs4 -peewee -sqlmodel -alembic -pytest -pytest-asyncio -numpy<2 -pandas -psycopg2-binary -aiohttp +discord.py==2.7.1 +pygsheets==2.0.6 +pydantic==2.12.5 +gsheets==0.6.1 +bs4==0.0.2 +peewee==4.0.1 +sqlmodel==0.0.37 +alembic==1.18.4 +numpy==1.26.4 +pandas==3.0.1 +psycopg2-binary==2.9.11 +aiohttp==3.13.3 # psycopg[binary] diff --git a/tests/test_card_embed_evolution.py b/tests/test_card_embed_evolution.py new file mode 100644 index 0000000..5a3b9e2 --- /dev/null +++ b/tests/test_card_embed_evolution.py @@ -0,0 +1,315 @@ +""" +Tests for WP-12: Tier Badge on Card Embed. + +What: Verifies that get_card_embeds() correctly prepends a tier badge to the +embed title when a card has evolution progress, and gracefully degrades when +the evolution API is unavailable. + +Why: The tier badge is a non-blocking UI enhancement. Any failure in the +evolution API must never prevent the card embed from rendering — this test +suite enforces that contract while also validating the badge format logic. +""" + +import pytest +from unittest.mock import AsyncMock, patch +import discord + +# --------------------------------------------------------------------------- +# Helpers / shared fixtures +# --------------------------------------------------------------------------- + + +def make_card( + player_id=42, + p_name="Mike Trout", + rarity_color="FFD700", + image="https://example.com/card.png", + headshot=None, + franchise="Los Angeles Angels", + bbref_id="troutmi01", + fangr_id=None, + strat_code="420420", + mlbclub="Los Angeles Angels", + cardset_name="2024 Season", +): + """ + Build the minimal card dict that get_card_embeds() expects, matching the + shape returned by the Paper Dynasty API (nested player / team / rarity). + + Using p_name='Mike Trout' as the canonical test name so we can assert + against '[Tx] Mike Trout' title strings without repeating the name. + """ + return { + "id": 9001, + "player": { + "player_id": player_id, + "p_name": p_name, + "rarity": {"color": rarity_color, "name": "Hall of Fame"}, + "image": image, + "image2": None, + "headshot": headshot, + "mlbclub": mlbclub, + "franchise": franchise, + "bbref_id": bbref_id, + "fangr_id": fangr_id, + "strat_code": strat_code, + "cost": 500, + "cardset": {"name": cardset_name}, + "pos_1": "CF", + "pos_2": None, + "pos_3": None, + "pos_4": None, + "pos_5": None, + "pos_6": None, + "pos_7": None, + "pos_8": None, + }, + "team": { + "id": 1, + "lname": "Test Team", + "logo": "https://example.com/logo.png", + "season": 7, + }, + } + + +def make_evo_state(tier: int) -> dict: + """Return a minimal evolution-state dict for a given tier.""" + return {"current_tier": tier, "xp": 100, "max_tier": 4} + + +EMPTY_PAPERDEX = {"count": 0, "paperdex": []} + + +def _db_get_side_effect(evo_response): + """ + Build a db_get coroutine side-effect that returns evo_response for + evolution/* endpoints and an empty paperdex for everything else. + """ + + async def _side_effect(endpoint, **kwargs): + if "evolution" in endpoint: + return evo_response + if "paperdex" in endpoint: + return EMPTY_PAPERDEX + return None + + return _side_effect + + +# --------------------------------------------------------------------------- +# Tier badge format — pure function tests (no Discord/API involved) +# --------------------------------------------------------------------------- + + +class TestTierBadgeFormat: + """ + Unit tests for the _get_tier_badge() helper that computes the badge string. + + Why separate: the badge logic is simple but error-prone at the boundary + between tier 3 and tier 4 (EVO). Testing it in isolation makes failures + immediately obvious without standing up the full embed machinery. + """ + + def _badge(self, tier: int) -> str: + """Inline mirror of the production badge logic for white-box testing.""" + if tier <= 0: + return "" + return f"[{'EVO' if tier >= 4 else f'T{tier}'}] " + + def test_tier_0_returns_empty_string(self): + """Tier 0 means no evolution progress — badge must be absent.""" + assert self._badge(0) == "" + + def test_negative_tier_returns_empty_string(self): + """Defensive: negative tiers (should not happen) must produce no badge.""" + assert self._badge(-1) == "" + + def test_tier_1_shows_T1(self): + assert self._badge(1) == "[T1] " + + def test_tier_2_shows_T2(self): + assert self._badge(2) == "[T2] " + + def test_tier_3_shows_T3(self): + assert self._badge(3) == "[T3] " + + def test_tier_4_shows_EVO(self): + """Tier 4 is fully evolved — badge changes from T4 to EVO.""" + assert self._badge(4) == "[EVO] " + + def test_tier_above_4_shows_EVO(self): + """Any tier >= 4 should display EVO (defensive against future tiers).""" + assert self._badge(5) == "[EVO] " + assert self._badge(99) == "[EVO] " + + +# --------------------------------------------------------------------------- +# Integration-style tests for get_card_embeds() title construction +# --------------------------------------------------------------------------- + + +class TestCardEmbedTierBadge: + """ + Validates that get_card_embeds() produces the correct title format when + evolution state is present or absent. + + Strategy: patch helpers.main.db_get to control what the evolution endpoint + returns, then call get_card_embeds() and inspect the resulting embed title. + """ + + @pytest.mark.asyncio + @pytest.mark.asyncio + async def test_no_evolution_state_shows_plain_name(self): + """ + When the evolution API returns None (404 or down), the embed title + must equal the player name with no badge prefix. + """ + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", new=AsyncMock(side_effect=_db_get_side_effect(None)) + ): + embeds = await get_card_embeds(card) + + assert len(embeds) > 0 + assert embeds[0].title == "Mike Trout" + + @pytest.mark.asyncio + async def test_tier_0_shows_plain_name(self): + """ + Tier 0 in the evolution state means no progress yet — no badge shown. + """ + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(0))), + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "Mike Trout" + + @pytest.mark.asyncio + async def test_tier_1_badge_in_title(self): + """Tier 1 card shows [T1] prefix in the embed title.""" + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(1))), + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "[T1] Mike Trout" + + @pytest.mark.asyncio + async def test_tier_2_badge_in_title(self): + """Tier 2 card shows [T2] prefix in the embed title.""" + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(2))), + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "[T2] Mike Trout" + + @pytest.mark.asyncio + async def test_tier_3_badge_in_title(self): + """Tier 3 card shows [T3] prefix in the embed title.""" + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(3))), + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "[T3] Mike Trout" + + @pytest.mark.asyncio + async def test_tier_4_shows_evo_badge(self): + """Fully evolved card (tier 4) shows [EVO] prefix instead of [T4].""" + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(4))), + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "[EVO] Mike Trout" + + @pytest.mark.asyncio + async def test_embed_color_unchanged_by_badge(self): + """ + The tier badge must not affect the embed color — rarity color is the + only driver of embed color, even for evolved cards. + + Why: embed color communicates card rarity to players. Silently breaking + it via evolution would confuse users. + """ + from helpers.main import get_card_embeds + + rarity_color = "FFD700" + card = make_card(p_name="Mike Trout", rarity_color=rarity_color) + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(3))), + ): + embeds = await get_card_embeds(card) + + expected_color = int(rarity_color, 16) + assert embeds[0].colour.value == expected_color + + @pytest.mark.asyncio + async def test_evolution_api_exception_shows_plain_name(self): + """ + When the evolution API raises an unexpected exception (network error, + server crash, etc.), the embed must still render with the plain player + name — no badge, no crash. + + This is the critical non-blocking contract for the feature. + """ + from helpers.main import get_card_embeds + + async def exploding_side_effect(endpoint, **kwargs): + if "evolution" in endpoint: + raise RuntimeError("simulated network failure") + if "paperdex" in endpoint: + return EMPTY_PAPERDEX + return None + + card = make_card(p_name="Mike Trout") + with patch( + "helpers.main.db_get", new=AsyncMock(side_effect=exploding_side_effect) + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "Mike Trout" + + @pytest.mark.asyncio + async def test_evolution_api_missing_current_tier_key(self): + """ + If the evolution response is present but lacks 'current_tier', the + embed must gracefully degrade to no badge (defensive against API drift). + """ + from helpers.main import get_card_embeds + + card = make_card(p_name="Mike Trout") + # Response exists but is missing the expected key + with patch( + "helpers.main.db_get", + new=AsyncMock(side_effect=_db_get_side_effect({"xp": 50})), + ): + embeds = await get_card_embeds(card) + + assert embeds[0].title == "Mike Trout" diff --git a/tests/test_complete_game_hook.py b/tests/test_complete_game_hook.py new file mode 100644 index 0000000..6b6f07f --- /dev/null +++ b/tests/test_complete_game_hook.py @@ -0,0 +1,201 @@ +""" +Tests for the WP-13 post-game callback integration hook. + +These tests verify that after a game is saved to the API, two additional +POST requests are fired in the correct order: + 1. POST season-stats/update-game/{game_id} — update player_season_stats + 2. POST evolution/evaluate-game/{game_id} — evaluate evolution milestones + +Key design constraints being tested: + - Season stats MUST be updated before evolution is evaluated (ordering). + - Failure of either evolution call must NOT propagate — the game result has + already been committed; evolution will self-heal on the next evaluate pass. + - Tier-up dicts returned by the evolution endpoint are passed to + notify_tier_completion so WP-14 can present them to the player. +""" + +import asyncio +import logging +import pytest +from unittest.mock import AsyncMock, MagicMock, call, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_channel(channel_id: int = 999) -> MagicMock: + ch = MagicMock() + ch.id = channel_id + return ch + + +async def _run_hook(db_post_mock, db_game_id: int = 42): + """ + Execute the post-game hook in isolation. + + We import the hook logic inline rather than calling the full + complete_game() function (which requires a live DB session, Discord + interaction, and Play object). The hook is a self-contained try/except + block so we replicate it verbatim here to test its behaviour. + """ + channel = _make_channel() + from command_logic.logic_gameplay import notify_tier_completion + + db_game = {"id": db_game_id} + + try: + await db_post_mock(f"season-stats/update-game/{db_game['id']}") + evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}") + if evo_result and evo_result.get("tier_ups"): + for tier_up in evo_result["tier_ups"]: + await notify_tier_completion(channel, tier_up) + except Exception: + pass # non-fatal — mirrors the logger.warning in production + + return channel + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_hook_posts_to_both_endpoints_in_order(): + """ + Both evolution endpoints are called, and season-stats comes first. + + The ordering is critical: player_season_stats must be populated before the + evolution engine tries to read them for milestone evaluation. + """ + db_post_mock = AsyncMock(return_value={}) + + await _run_hook(db_post_mock, db_game_id=42) + + assert db_post_mock.call_count == 2 + calls = db_post_mock.call_args_list + # First call must be season-stats + assert calls[0] == call("season-stats/update-game/42") + # Second call must be evolution evaluate + assert calls[1] == call("evolution/evaluate-game/42") + + +@pytest.mark.asyncio +async def test_hook_is_nonfatal_when_db_post_raises(): + """ + A failure inside the hook must not raise to the caller. + + The game result is already persisted when the hook runs. If the evolution + API is down or returns an error, we log a warning and continue — the game + completion flow must not be interrupted. + """ + db_post_mock = AsyncMock(side_effect=Exception("evolution API unavailable")) + + # Should not raise + try: + await _run_hook(db_post_mock, db_game_id=7) + except Exception as exc: + pytest.fail(f"Hook raised unexpectedly: {exc}") + + +@pytest.mark.asyncio +async def test_hook_processes_tier_ups_from_evo_result(): + """ + When the evolution endpoint returns tier_ups, each entry is forwarded to + notify_tier_completion. + + This confirms the data path between the API response and the WP-14 + notification stub so that WP-14 only needs to replace the stub body. + """ + tier_ups = [ + {"player_id": 101, "old_tier": 1, "new_tier": 2}, + {"player_id": 202, "old_tier": 2, "new_tier": 3}, + ] + + async def fake_db_post(endpoint): + if "evolution" in endpoint: + return {"tier_ups": tier_ups} + return {} + + db_post_mock = AsyncMock(side_effect=fake_db_post) + + with patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify: + channel = _make_channel() + db_game = {"id": 99} + + try: + await db_post_mock(f"season-stats/update-game/{db_game['id']}") + evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}") + if evo_result and evo_result.get("tier_ups"): + for tier_up in evo_result["tier_ups"]: + await mock_notify(channel, tier_up) + except Exception: + pass + + assert mock_notify.call_count == 2 + # Verify both tier_up dicts were forwarded + forwarded = [c.args[1] for c in mock_notify.call_args_list] + assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded + assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded + + +@pytest.mark.asyncio +async def test_hook_no_tier_ups_does_not_call_notify(): + """ + When the evolution response has no tier_ups (empty list or missing key), + notify_tier_completion is never called. + + Avoids spurious Discord messages for routine game completions. + """ + + async def fake_db_post(endpoint): + if "evolution" in endpoint: + return {"tier_ups": []} + return {} + + db_post_mock = AsyncMock(side_effect=fake_db_post) + + with patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify: + channel = _make_channel() + db_game = {"id": 55} + + try: + await db_post_mock(f"season-stats/update-game/{db_game['id']}") + evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}") + if evo_result and evo_result.get("tier_ups"): + for tier_up in evo_result["tier_ups"]: + await mock_notify(channel, tier_up) + except Exception: + pass + + mock_notify.assert_not_called() + + +@pytest.mark.asyncio +async def test_notify_tier_completion_stub_logs_and_does_not_raise(caplog): + """ + The WP-14 stub must log the event and return cleanly. + + Verifies the contract that WP-14 can rely on: the function accepts + (channel, tier_up) and does not raise, so the hook's for-loop is safe. + """ + from command_logic.logic_gameplay import notify_tier_completion + + channel = _make_channel(channel_id=123) + tier_up = {"player_id": 77, "old_tier": 0, "new_tier": 1} + + with caplog.at_level(logging.INFO): + await notify_tier_completion(channel, tier_up) + + # At minimum one log message should reference the channel or tier_up data + assert any( + "notify_tier_completion" in rec.message or "77" in rec.message + for rec in caplog.records + ) diff --git a/tests/test_evolution_commands.py b/tests/test_evolution_commands.py new file mode 100644 index 0000000..eb65458 --- /dev/null +++ b/tests/test_evolution_commands.py @@ -0,0 +1,173 @@ +"""Tests for the evolution status command helpers (WP-11). + +Unit tests for progress bar rendering, entry formatting, tier display +names, close-to-tierup filtering, and edge cases. No Discord bot or +API calls required — these test pure functions only. +""" + +import pytest +from cogs.players_new.evolution import ( + render_progress_bar, + format_evo_entry, + is_close_to_tierup, + TIER_NAMES, + FORMULA_SHORTHANDS, +) + +# --------------------------------------------------------------------------- +# render_progress_bar +# --------------------------------------------------------------------------- + + +class TestRenderProgressBar: + def test_80_percent_filled(self): + """120/149 should be ~80% filled (8 of 10 chars).""" + result = render_progress_bar(120, 149, width=10) + assert "[========--]" in result + assert "120/149" in result + + def test_zero_progress(self): + """0/37 should be empty bar.""" + result = render_progress_bar(0, 37, width=10) + assert "[----------]" in result + assert "0/37" in result + + def test_full_progress_not_evolved(self): + """Value at threshold shows full bar.""" + result = render_progress_bar(149, 149, width=10) + assert "[==========]" in result + assert "149/149" in result + + def test_fully_evolved(self): + """next_threshold=None means fully evolved.""" + result = render_progress_bar(900, None, width=10) + assert "FULLY EVOLVED" in result + assert "[==========]" in result + + def test_over_threshold_capped(self): + """Value exceeding threshold still caps at 100%.""" + result = render_progress_bar(200, 149, width=10) + assert "[==========]" in result + + +# --------------------------------------------------------------------------- +# format_evo_entry +# --------------------------------------------------------------------------- + + +class TestFormatEvoEntry: + def test_batter_t1_to_t2(self): + """Batter at T1 progressing toward T2.""" + state = { + "current_tier": 1, + "current_value": 120.0, + "next_threshold": 149, + "fully_evolved": False, + "track": {"card_type": "batter"}, + } + result = format_evo_entry(state) + assert "(PA+TB×2)" in result + assert "Initiate → Rising" in result + + def test_pitcher_sp(self): + """SP track shows IP+K formula.""" + state = { + "current_tier": 0, + "current_value": 5.0, + "next_threshold": 10, + "fully_evolved": False, + "track": {"card_type": "sp"}, + } + result = format_evo_entry(state) + assert "(IP+K)" in result + assert "Unranked → Initiate" in result + + def test_fully_evolved_entry(self): + """Fully evolved card shows T4 — Evolved.""" + state = { + "current_tier": 4, + "current_value": 900.0, + "next_threshold": None, + "fully_evolved": True, + "track": {"card_type": "batter"}, + } + result = format_evo_entry(state) + assert "FULLY EVOLVED" in result + assert "Evolved" in result + + +# --------------------------------------------------------------------------- +# is_close_to_tierup +# --------------------------------------------------------------------------- + + +class TestIsCloseToTierup: + def test_at_80_percent(self): + """Exactly 80% of threshold counts as close.""" + state = {"current_value": 119.2, "next_threshold": 149} + assert is_close_to_tierup(state, threshold_pct=0.80) + + def test_below_80_percent(self): + """Below 80% is not close.""" + state = {"current_value": 100, "next_threshold": 149} + assert not is_close_to_tierup(state, threshold_pct=0.80) + + def test_fully_evolved_not_close(self): + """Fully evolved (no next threshold) is not close.""" + state = {"current_value": 900, "next_threshold": None} + assert not is_close_to_tierup(state) + + def test_zero_threshold(self): + """Zero threshold edge case returns False.""" + state = {"current_value": 0, "next_threshold": 0} + assert not is_close_to_tierup(state) + + +# --------------------------------------------------------------------------- +# Tier names and formula shorthands +# --------------------------------------------------------------------------- + + +class TestConstants: + def test_all_tier_names_present(self): + """All 5 tiers (0-4) have display names.""" + assert len(TIER_NAMES) == 5 + for i in range(5): + assert i in TIER_NAMES + + def test_tier_name_values(self): + assert TIER_NAMES[0] == "Unranked" + assert TIER_NAMES[1] == "Initiate" + assert TIER_NAMES[2] == "Rising" + assert TIER_NAMES[3] == "Ascendant" + assert TIER_NAMES[4] == "Evolved" + + def test_formula_shorthands(self): + assert FORMULA_SHORTHANDS["batter"] == "PA+TB×2" + assert FORMULA_SHORTHANDS["sp"] == "IP+K" + assert FORMULA_SHORTHANDS["rp"] == "IP+K" + + +# --------------------------------------------------------------------------- +# Empty / edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_missing_track_defaults(self): + """State with missing track info still formats without error.""" + state = { + "current_tier": 0, + "current_value": 0, + "next_threshold": 37, + "fully_evolved": False, + "track": {}, + } + result = format_evo_entry(state) + assert isinstance(result, str) + + def test_state_with_no_keys(self): + """Completely empty state dict doesn't crash.""" + state = {} + result = format_evo_entry(state) + assert isinstance(result, str) diff --git a/tests/test_evolution_notifications.py b/tests/test_evolution_notifications.py new file mode 100644 index 0000000..1f1256c --- /dev/null +++ b/tests/test_evolution_notifications.py @@ -0,0 +1,259 @@ +""" +Tests for Evolution Tier Completion Notification embeds. + +These tests verify that: +1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color). +2. Tier 4 (Fully Evolved) embeds include the special title, description, and note field. +3. Multiple tier-up events each produce a separate embed. +4. An empty tier-up list results in no channel sends. + +The channel interaction is mocked because we are testing the embed content, not Discord +network I/O. Notification failure must never affect game flow, so the non-fatal path +is also exercised. +""" + +import pytest +from unittest.mock import AsyncMock + +import discord + +from helpers.evolution_notifs import build_tier_up_embed, notify_tier_completion + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def make_tier_up( + player_name="Mike Trout", + old_tier=1, + new_tier=2, + track_name="Batter", + current_value=150, +): + """Return a minimal tier_up dict matching the expected shape.""" + return { + "player_name": player_name, + "old_tier": old_tier, + "new_tier": new_tier, + "track_name": track_name, + "current_value": current_value, + } + + +# --------------------------------------------------------------------------- +# Unit: build_tier_up_embed — tiers 1-3 (standard tier-up) +# --------------------------------------------------------------------------- + + +class TestBuildTierUpEmbed: + """Verify that build_tier_up_embed produces correctly structured embeds.""" + + def test_title_is_evolution_tier_up(self): + """Title must read 'Evolution Tier Up!' for any non-max tier.""" + tier_up = make_tier_up(new_tier=2) + embed = build_tier_up_embed(tier_up) + assert embed.title == "Evolution Tier Up!" + + def test_description_contains_player_name(self): + """Description must contain the player's name.""" + tier_up = make_tier_up(player_name="Mike Trout", new_tier=2) + embed = build_tier_up_embed(tier_up) + assert "Mike Trout" in embed.description + + def test_description_contains_new_tier_name(self): + """Description must include the human-readable tier name for the new tier.""" + tier_up = make_tier_up(new_tier=2) + embed = build_tier_up_embed(tier_up) + # Tier 2 display name is "Rising" + assert "Rising" in embed.description + + def test_description_contains_track_name(self): + """Description must mention the evolution track (e.g., 'Batter').""" + tier_up = make_tier_up(track_name="Batter", new_tier=2) + embed = build_tier_up_embed(tier_up) + assert "Batter" in embed.description + + def test_tier1_color_is_green(self): + """Tier 1 uses green (0x2ecc71).""" + tier_up = make_tier_up(old_tier=0, new_tier=1) + embed = build_tier_up_embed(tier_up) + assert embed.color.value == 0x2ECC71 + + def test_tier2_color_is_gold(self): + """Tier 2 uses gold (0xf1c40f).""" + tier_up = make_tier_up(old_tier=1, new_tier=2) + embed = build_tier_up_embed(tier_up) + assert embed.color.value == 0xF1C40F + + def test_tier3_color_is_purple(self): + """Tier 3 uses purple (0x9b59b6).""" + tier_up = make_tier_up(old_tier=2, new_tier=3) + embed = build_tier_up_embed(tier_up) + assert embed.color.value == 0x9B59B6 + + def test_footer_text_is_paper_dynasty_evolution(self): + """Footer text must be 'Paper Dynasty Evolution' for brand consistency.""" + tier_up = make_tier_up(new_tier=2) + embed = build_tier_up_embed(tier_up) + assert embed.footer.text == "Paper Dynasty Evolution" + + def test_returns_discord_embed_instance(self): + """Return type must be discord.Embed so it can be sent directly.""" + tier_up = make_tier_up(new_tier=2) + embed = build_tier_up_embed(tier_up) + assert isinstance(embed, discord.Embed) + + +# --------------------------------------------------------------------------- +# Unit: build_tier_up_embed — tier 4 (fully evolved) +# --------------------------------------------------------------------------- + + +class TestBuildTierUpEmbedFullyEvolved: + """Verify that tier 4 (Fully Evolved) embeds use special formatting.""" + + def test_title_is_fully_evolved(self): + """Tier 4 title must be 'FULLY EVOLVED!' to emphasise max achievement.""" + tier_up = make_tier_up(old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + assert embed.title == "FULLY EVOLVED!" + + def test_description_mentions_maximum_evolution(self): + """Tier 4 description must mention 'maximum evolution' per the spec.""" + tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + assert "maximum evolution" in embed.description.lower() + + def test_description_contains_player_name(self): + """Player name must appear in the tier 4 description.""" + tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + assert "Mike Trout" in embed.description + + def test_description_contains_track_name(self): + """Track name must appear in the tier 4 description.""" + tier_up = make_tier_up(track_name="Batter", old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + assert "Batter" in embed.description + + def test_tier4_color_is_teal(self): + """Tier 4 uses teal (0x1abc9c) to visually distinguish max evolution.""" + tier_up = make_tier_up(old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + assert embed.color.value == 0x1ABC9C + + def test_note_field_present(self): + """Tier 4 must include a note field about future rating boosts.""" + tier_up = make_tier_up(old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + field_names = [f.name for f in embed.fields] + assert any( + "rating" in name.lower() + or "boost" in name.lower() + or "note" in name.lower() + for name in field_names + ), "Expected a field mentioning rating boosts for tier 4 embed" + + def test_note_field_value_mentions_future_update(self): + """The note field value must reference the future rating boost update.""" + tier_up = make_tier_up(old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + note_field = next( + ( + f + for f in embed.fields + if "rating" in f.name.lower() + or "boost" in f.name.lower() + or "note" in f.name.lower() + ), + None, + ) + assert note_field is not None + assert ( + "future" in note_field.value.lower() or "update" in note_field.value.lower() + ) + + def test_footer_text_is_paper_dynasty_evolution(self): + """Footer must remain 'Paper Dynasty Evolution' for tier 4 as well.""" + tier_up = make_tier_up(old_tier=3, new_tier=4) + embed = build_tier_up_embed(tier_up) + assert embed.footer.text == "Paper Dynasty Evolution" + + +# --------------------------------------------------------------------------- +# Unit: notify_tier_completion — multiple and empty cases +# --------------------------------------------------------------------------- + + +class TestNotifyTierCompletion: + """Verify that notify_tier_completion sends the right number of messages.""" + + @pytest.mark.asyncio + async def test_single_tier_up_sends_one_message(self): + """A single tier-up event sends exactly one embed to the channel.""" + channel = AsyncMock() + tier_up = make_tier_up(new_tier=2) + await notify_tier_completion(channel, tier_up) + channel.send.assert_called_once() + + @pytest.mark.asyncio + async def test_sends_embed_not_plain_text(self): + """The channel.send call must use the embed= keyword, not content=.""" + channel = AsyncMock() + tier_up = make_tier_up(new_tier=2) + await notify_tier_completion(channel, tier_up) + _, kwargs = channel.send.call_args + assert ( + "embed" in kwargs + ), "notify_tier_completion must send an embed, not plain text" + + @pytest.mark.asyncio + async def test_embed_type_is_discord_embed(self): + """The embed passed to channel.send must be a discord.Embed instance.""" + channel = AsyncMock() + tier_up = make_tier_up(new_tier=2) + await notify_tier_completion(channel, tier_up) + _, kwargs = channel.send.call_args + assert isinstance(kwargs["embed"], discord.Embed) + + @pytest.mark.asyncio + async def test_notification_failure_does_not_raise(self): + """If channel.send raises, notify_tier_completion must swallow it so game flow is unaffected.""" + channel = AsyncMock() + channel.send.side_effect = Exception("Discord API unavailable") + tier_up = make_tier_up(new_tier=2) + # Should not raise + await notify_tier_completion(channel, tier_up) + + @pytest.mark.asyncio + async def test_multiple_tier_ups_caller_sends_multiple_embeds(self): + """ + Callers are responsible for iterating tier-up events; each call to + notify_tier_completion sends a separate embed. This test simulates + three consecutive calls (3 events) and asserts 3 sends occurred. + """ + channel = AsyncMock() + events = [ + make_tier_up(player_name="Mike Trout", new_tier=2), + make_tier_up(player_name="Aaron Judge", new_tier=1), + make_tier_up(player_name="Shohei Ohtani", new_tier=3), + ] + for event in events: + await notify_tier_completion(channel, event) + assert ( + channel.send.call_count == 3 + ), "Each tier-up event must produce its own embed (no batching)" + + @pytest.mark.asyncio + async def test_no_tier_ups_means_no_sends(self): + """ + When the caller has an empty list of tier-up events and simply + does not call notify_tier_completion, zero sends happen. + This explicitly guards against any accidental unconditional send. + """ + channel = AsyncMock() + tier_up_events = [] + for event in tier_up_events: + await notify_tier_completion(channel, event) + channel.send.assert_not_called()