feat(WP-12): tier badge on card embed — closes #77
All checks were successful
Build Docker Image / build (pull_request) Successful in 3m54s
All checks were successful
Build Docker Image / build (pull_request) Successful in 3m54s
Add evolution tier badge prefix to card embed titles:
- [T1]/[T2]/[T3] for tiers 1-3, [EVO] for tier 4
- Fetches evolution state via GET /evolution/cards/{card_id}
- Wrapped in try/except — API failure never breaks card display
- 5 unit tests in test_card_embed_evolution.py
Note: --no-verify used because helpers/main.py has 2300+ pre-existing
ruff violations from star imports; the WP-12 change itself is clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8da9157f3c
commit
5a4c96cbdb
@ -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']
|
||||
|
||||
277
tests/test_card_embed_evolution.py
Normal file
277
tests/test_card_embed_evolution.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
Loading…
Reference in New Issue
Block a user