Compare commits
12 Commits
main
...
card-evolu
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8a08fff3 | |||
| 92228a21af | |||
| 3543ed5a32 | |||
| 6aeef36f20 | |||
|
|
746ffa2263 | ||
|
|
596a3ec414 | ||
|
|
303b7670d7 | ||
|
|
6c725009db | ||
|
|
93e0ab9a63 | ||
|
|
b4c41aa7ee | ||
|
|
fce9cc5650 | ||
|
|
5a4c96cbdb |
@ -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')
|
||||
await bot.add_cog(Evolution(bot))
|
||||
|
||||
logging.getLogger("discord_app").info("All player cogs loaded successfully")
|
||||
|
||||
206
cogs/players_new/evolution.py
Normal file
206
cogs/players_new/evolution.py
Normal file
@ -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)
|
||||
@ -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()
|
||||
|
||||
|
||||
106
helpers/evolution_notifs.py
Normal file
106
helpers/evolution_notifs.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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']
|
||||
|
||||
315
tests/test_card_embed_evolution.py
Normal file
315
tests/test_card_embed_evolution.py
Normal file
@ -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"
|
||||
201
tests/test_complete_game_hook.py
Normal file
201
tests/test_complete_game_hook.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""
|
||||
Tests for the WP-13 post-game callback integration hook.
|
||||
|
||||
These tests verify that after a game is saved to the API, two additional
|
||||
POST requests are fired in the correct order:
|
||||
1. POST season-stats/update-game/{game_id} — update player_season_stats
|
||||
2. POST 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
|
||||
)
|
||||
173
tests/test_evolution_commands.py
Normal file
173
tests/test_evolution_commands.py
Normal file
@ -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)
|
||||
259
tests/test_evolution_notifications.py
Normal file
259
tests/test_evolution_notifications.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user