paper-dynasty-discord/tests/test_refractor_notifs.py
Cal Corum b34dcc390c
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
test: add comprehensive refractor test cases (T1-5 through T3-5)
Implements all gap tests identified by PO agents across three existing
test files. No new files created — tests added to existing modules.

T1-5 (test_refractor_notifs): Expose WP-14 integration bug — minimal
stub dict {player_id, old_tier, new_tier} causes KeyError in
build_tier_up_embed because player_name/track_name use bare dict access.
Documents the bug contract so WP-14 implementers know what to fix.

T1-6 (test_refractor_commands): Divergence tripwire — imports TIER_NAMES
from both cogs.refractor and helpers.refractor_notifs and asserts deep
equality. Will fail the moment the two copies fall out of sync.

T1-7 (test_card_embed_refractor): TIER_BADGES format contract — asserts
that wrapping helpers.main badge values in brackets produces cogs.refractor
badge values (e.g. "BC" -> "[BC]") for all tiers.

T2-7 (test_refractor_notifs): notify_tier_completion with None channel
must not raise — the try/except absorbs AttributeError from None.send().

T2-8 (test_refractor_commands): All-T4 apply_close_filter returns empty
list. Documents intended behaviour for tier=4 + progress="close" combo.

T3-2 (test_refractor_commands): Malformed API response handling —
format_refractor_entry must use fallbacks ("Unknown", 0) for missing keys.

T3-3 (test_refractor_commands): Progress bar boundary precision — 1/100,
99/100, 0/100, and negative current values.

T3-4 (test_refractor_commands): RP formula label — card_type="rp" shows
"IP+K" (previously only "sp" was tested).

T3-5 (test_refractor_commands): Unknown card_type falls back to raw string
as the formula label without crashing.

112 tests pass (23 new, 89 pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:59:18 -05:00

349 lines
14 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 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()
# ---------------------------------------------------------------------------
# T1-5: tier_up dict shape mismatch — WP-14 integration blocker
# ---------------------------------------------------------------------------
class TestTierUpDictShapeMismatch:
"""
T1-5: Expose the latent integration bug where the post-game hook passes a
minimal tier_up dict (only player_id, old_tier, new_tier) but
build_tier_up_embed expects player_name, old_tier, new_tier, track_name,
and current_value.
Why this matters: the hook test (test_complete_game_hook.py) confirms the
plumbing forwards tier_up dicts from the API response to notify_tier_completion.
However, the real API response may omit player_name/track_name. If
build_tier_up_embed does a bare dict access (tier_up["player_name"]) without
a fallback, it will raise KeyError in production. This test documents the
current behaviour (crash vs. graceful degradation) so WP-14 implementers
know to either harden the embed builder or ensure the API always returns
the full shape.
"""
def test_minimal_stub_shape_raises_key_error(self):
"""
Calling build_tier_up_embed with only {player_id, old_tier, new_tier}
(the minimal shape used by the post-game hook stub) raises KeyError
because player_name and track_name are accessed via bare dict lookup.
This is the latent bug: the hook passes stub-shaped dicts but the embed
builder expects the full notification shape. WP-14 must ensure either
(a) the API returns the full shape or (b) build_tier_up_embed degrades
gracefully with .get() fallbacks.
"""
minimal_stub = {
"player_id": 101,
"old_tier": 1,
"new_tier": 2,
}
# Document that this raises — it's the bug we're exposing, not a passing test.
with pytest.raises(KeyError):
build_tier_up_embed(minimal_stub)
def test_full_shape_does_not_raise(self):
"""
Confirm that supplying the full expected shape (player_name, old_tier,
new_tier, track_name, current_value) does NOT raise, establishing the
correct contract for callers.
"""
full_shape = make_tier_up(
player_name="Mike Trout",
old_tier=1,
new_tier=2,
track_name="Batter",
current_value=150,
)
# Must not raise
embed = build_tier_up_embed(full_shape)
assert embed is not None
# ---------------------------------------------------------------------------
# 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)