Merge pull request 'feat(WP-12): tier badge on card embed (#77)' (#91) from feature/wp12-tier-badge into card-evolution

Reviewed-on: #91
This commit is contained in:
cal 2026-03-18 21:20:40 +00:00
commit 3543ed5a32
2 changed files with 332 additions and 1 deletions

View File

@ -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']

View 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"