Merge pull request 'feat: WP-14 tier completion notification embeds' (#112) from feature/wp14-tier-notifications into main
This commit is contained in:
commit
187ae854ca
108
helpers/refractor_notifs.py
Normal file
108
helpers/refractor_notifs.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""
|
||||
Refractor Tier Completion Notifications
|
||||
|
||||
Builds and sends Discord embeds when a player completes a refractor 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: "Base Card",
|
||||
1: "Base Chrome",
|
||||
2: "Refractor",
|
||||
3: "Gold Refractor",
|
||||
4: "Superfractor",
|
||||
}
|
||||
|
||||
# Tier-specific embed colors.
|
||||
TIER_COLORS = {
|
||||
1: 0x2ECC71, # green
|
||||
2: 0xF1C40F, # gold
|
||||
3: 0x9B59B6, # purple
|
||||
4: 0x1ABC9C, # teal (superfractor)
|
||||
}
|
||||
|
||||
FOOTER_TEXT = "Paper Dynasty Refractor"
|
||||
|
||||
|
||||
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:
|
||||
# Superfractor — special title and description.
|
||||
embed = discord.Embed(
|
||||
title="SUPERFRACTOR!",
|
||||
description=(
|
||||
f"**{player_name}** has reached maximum refractor tier 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="Refractor 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: discord.abc.Messageable, 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.abc.Messageable (e.g. discord.TextChannel).
|
||||
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,
|
||||
)
|
||||
259
tests/test_refractor_notifs.py
Normal file
259
tests/test_refractor_notifs.py
Normal file
@ -0,0 +1,259 @@
|
||||
"""
|
||||
Tests for Refractor 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 (Superfractor) 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.refractor_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_refractor_tier_up(self):
|
||||
"""Title must read 'Refractor 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 == "Refractor 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 "Refractor"
|
||||
assert "Refractor" in embed.description
|
||||
|
||||
def test_description_contains_track_name(self):
|
||||
"""Description must mention the refractor 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_refractor(self):
|
||||
"""Footer text must be 'Paper Dynasty Refractor' for brand consistency."""
|
||||
tier_up = make_tier_up(new_tier=2)
|
||||
embed = build_tier_up_embed(tier_up)
|
||||
assert embed.footer.text == "Paper Dynasty Refractor"
|
||||
|
||||
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 (superfractor)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildTierUpEmbedSuperfractor:
|
||||
"""Verify that tier 4 (Superfractor) embeds use special formatting."""
|
||||
|
||||
def test_title_is_superfractor(self):
|
||||
"""Tier 4 title must be 'SUPERFRACTOR!' 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 == "SUPERFRACTOR!"
|
||||
|
||||
def test_description_mentions_maximum_refractor_tier(self):
|
||||
"""Tier 4 description must mention 'maximum refractor tier' 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 refractor tier" 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 superfractor."""
|
||||
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_refractor(self):
|
||||
"""Footer must remain 'Paper Dynasty Refractor' 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 Refractor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
@ -1,59 +0,0 @@
|
||||
import discord
|
||||
|
||||
# Tier colors as Discord embed color integers
|
||||
TIER_COLORS = {
|
||||
1: 0x57F287, # green
|
||||
2: 0xF1C40F, # gold
|
||||
3: 0x9B59B6, # purple
|
||||
4: 0x1ABC9C, # teal
|
||||
}
|
||||
|
||||
MAX_TIER = 4
|
||||
|
||||
|
||||
def tier_up_embed(
|
||||
player_name: str, tier: int, tier_name: str, track_name: str
|
||||
) -> discord.Embed:
|
||||
"""
|
||||
Build a Discord embed for a single evolution tier-up event.
|
||||
|
||||
For tier 4 (fully evolved), uses a distinct title, description, and footer.
|
||||
For tiers 1–3, uses the standard tier-up format.
|
||||
"""
|
||||
color = TIER_COLORS.get(tier, 0xFFFFFF)
|
||||
|
||||
if tier == MAX_TIER:
|
||||
embed = discord.Embed(
|
||||
title="FULLY EVOLVED!",
|
||||
description=f"{player_name} has reached maximum evolution on the {track_name} track",
|
||||
color=color,
|
||||
)
|
||||
embed.set_footer(text="Rating boosts coming in a future update!")
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title="Evolution Tier Up!",
|
||||
description=f"{player_name} reached Tier {tier} ({tier_name}) on the {track_name} track",
|
||||
color=color,
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
def build_tier_embeds(tier_ups: list) -> list:
|
||||
"""
|
||||
Build a list of Discord embeds for all tier-up events in a game.
|
||||
|
||||
Each item in tier_ups should be a dict with keys:
|
||||
player_name (str), tier (int), tier_name (str), track_name (str)
|
||||
|
||||
Returns an empty list if there are no tier-ups.
|
||||
"""
|
||||
return [
|
||||
tier_up_embed(
|
||||
player_name=t["player_name"],
|
||||
tier=t["tier"],
|
||||
tier_name=t["tier_name"],
|
||||
track_name=t["track_name"],
|
||||
)
|
||||
for t in tier_ups
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user