paper-dynasty-discord/tests/test_refractor_notifs.py
Cal Corum 80e99b075f
All checks were successful
Ruff Lint / lint (pull_request) Successful in 15s
chore: fix stale docstring referencing removed note field
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:09:21 -05:00

304 lines
12 KiB
Python

"""
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 color.
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_no_extra_fields(self):
"""Tier 4 embed should have no extra fields — boosts are live, no teaser needed."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert len(embed.fields) == 0
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()
# ---------------------------------------------------------------------------
# T1-5: tier_up dict shape validation
# ---------------------------------------------------------------------------
class TestTierUpDictShapeValidation:
"""
T1-5: Verify build_tier_up_embed handles valid API shapes correctly and
rejects malformed input.
The evaluate-game API endpoint returns the full shape (player_name,
old_tier, new_tier, track_name, current_value). These tests guard the
contract between the API response and the embed builder.
"""
def test_empty_dict_raises_key_error(self):
"""
An empty dict must raise KeyError — guards against callers passing
unrelated or completely malformed data.
"""
with pytest.raises(KeyError):
build_tier_up_embed({})
def test_full_api_shape_builds_embed(self):
"""
The full shape returned by the evaluate-game endpoint builds a valid
embed without error.
"""
full_shape = make_tier_up(
player_name="Mike Trout",
old_tier=1,
new_tier=2,
track_name="Batter Track",
current_value=150,
)
embed = build_tier_up_embed(full_shape)
assert embed is not None
assert "Mike Trout" in embed.description
# ---------------------------------------------------------------------------
# T2-7: notify_tier_completion with None channel
# ---------------------------------------------------------------------------
class TestNotifyTierCompletionNoneChannel:
"""
T2-7: notify_tier_completion must not propagate exceptions when the channel
is None.
Why: the post-game hook may call notify_tier_completion before a valid
channel is resolved (e.g. in tests, or if the scoreboard channel lookup
fails). The try/except in notify_tier_completion should catch the
AttributeError from None.send() so game flow is never interrupted.
"""
@pytest.mark.asyncio
async def test_none_channel_does_not_raise(self):
"""
Passing None as the channel argument must not raise.
None.send() raises AttributeError; the try/except in
notify_tier_completion is expected to absorb it silently.
"""
tier_up = make_tier_up(new_tier=2)
# Should not raise regardless of channel being None
await notify_tier_completion(None, tier_up)