""" 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 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)