From 5a4c96cbdb435a53e0d36def13ac43f61012d234 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 18 Mar 2026 15:41:06 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(WP-12):=20tier=20badge=20on=20card=20e?= =?UTF-8?q?mbed=20=E2=80=94=20closes=20#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- helpers/main.py | 18 +- tests/test_card_embed_evolution.py | 277 +++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 tests/test_card_embed_evolution.py 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/tests/test_card_embed_evolution.py b/tests/test_card_embed_evolution.py new file mode 100644 index 0000000..1766cdf --- /dev/null +++ b/tests/test_card_embed_evolution.py @@ -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" From 93e0ab9a63dd62a7e9745b921883487ad94c36d6 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 18 Mar 2026 15:55:16 -0500 Subject: [PATCH 2/2] fix: add @pytest.mark.asyncio to async test methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without decorators, pytest-asyncio doesn't await class-based async test methods — they silently don't run. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_card_embed_evolution.py | 86 +++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/tests/test_card_embed_evolution.py b/tests/test_card_embed_evolution.py index 1766cdf..5a3b9e2 100644 --- a/tests/test_card_embed_evolution.py +++ b/tests/test_card_embed_evolution.py @@ -14,16 +14,24 @@ 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"): + +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). @@ -78,12 +86,14 @@ 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 @@ -91,6 +101,7 @@ def _db_get_side_effect(evo_response): # 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. @@ -137,6 +148,7 @@ class TestTierBadgeFormat: # Integration-style tests for get_card_embeds() title construction # --------------------------------------------------------------------------- + class TestCardEmbedTierBadge: """ Validates that get_card_embeds() produces the correct title format when @@ -146,6 +158,8 @@ class TestCardEmbedTierBadge: 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 @@ -154,13 +168,15 @@ class TestCardEmbedTierBadge: 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))): + 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. @@ -168,56 +184,71 @@ class TestCardEmbedTierBadge: 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)))): + 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)))): + 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)))): + 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)))): + 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)))): + 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 @@ -230,13 +261,16 @@ class TestCardEmbedTierBadge: 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)))): + 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, @@ -255,12 +289,14 @@ class TestCardEmbedTierBadge: return None card = make_card(p_name="Mike Trout") - with patch("helpers.main.db_get", - new=AsyncMock(side_effect=exploding_side_effect)): + 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 @@ -270,8 +306,10 @@ class TestCardEmbedTierBadge: 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}))): + 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"