""" Tests for the gauntlet completion recap embed (Roadmap 2.4a). These tests verify: 1. build_gauntlet_recap_embed produces an embed with the required fields (title, champion, record, prize distribution) for a completed run. 2. The embed title always starts with "Gauntlet Complete:". 3. The champion field contains the team name and, when gmid is present, a Discord user mention (<@gmid>). 4. The prize distribution marks earned rewards with ✅ and unearned with ❌ or ⬜ depending on whether losses were exceeded. 5. post_gauntlet_recap is a no-op when channel is None (graceful fallback). 6. post_gauntlet_recap calls channel.send exactly once on success. 7. post_gauntlet_recap does not raise when channel.send raises — gauntlet completion must never be interrupted by a recap failure. The builder is a pure synchronous function so tests do not require an async event loop; only the async sender tests use pytest-asyncio. """ import pytest from unittest.mock import AsyncMock, MagicMock, patch import discord from gauntlets import build_gauntlet_recap_embed, post_gauntlet_recap # --------------------------------------------------------------------------- # Test data helpers # --------------------------------------------------------------------------- def make_run(wins: int = 10, losses: int = 1, run_id: int = 42) -> dict: """Return a minimal gauntletruns API dict for a completed run.""" return { "id": run_id, "wins": wins, "losses": losses, "gauntlet": {"id": 9, "name": "2005 Live"}, "team": {"lname": "Gauntlet-NCB", "logo": None}, } def make_event(event_id: int = 9, name: str = "2005 Live") -> dict: """Return a minimal events API dict.""" return { "id": event_id, "name": name, "url": "https://example.com/gauntlet.png", "short_desc": "Go 10-0!", } def make_main_team(gmid: int = 123456789, lname: str = "Normal CornBelters") -> dict: """Return a minimal teams API dict for the player's real team.""" return { "id": 31, "lname": lname, "gmid": gmid, "logo": "https://example.com/logo.png", } def make_rewards() -> list[dict]: """Return a representative gauntletrewards list for a gauntlet.""" return [ { "win_num": 3, "loss_max": 2, "reward": {"money": 500, "player": None, "pack_type": None}, }, { "win_num": 7, "loss_max": 2, "reward": { "money": None, "player": None, "pack_type": {"id": 1, "name": "Standard"}, }, }, { "win_num": 10, "loss_max": 0, "reward": { "money": None, "player": { "player_id": 99, "description": "Babe Ruth HoF", }, "pack_type": None, }, }, ] # --------------------------------------------------------------------------- # Unit: build_gauntlet_recap_embed # --------------------------------------------------------------------------- class TestBuildGauntletRecapEmbed: """Verify the embed produced by the synchronous builder function.""" def test_title_starts_with_gauntlet_complete(self): """Title must start with 'Gauntlet Complete:' followed by the event name. This is the canonical format expected by the PO spec and makes the embed immediately recognisable in the channel feed. """ embed = build_gauntlet_recap_embed( make_run(), make_event(), make_main_team(), make_rewards() ) assert embed.title.startswith("Gauntlet Complete:") assert "2005 Live" in embed.title def test_embed_colour_is_gold(self): """Embed colour must be the gold/champion accent (0xFFD700). Gold is the PO-specified accent for champion-level events. """ embed = build_gauntlet_recap_embed( make_run(), make_event(), make_main_team(), make_rewards() ) assert embed.color.value == 0xFFD700 def test_champion_field_contains_team_name(self): """The Champion field must display the team's long name. Players identify with their team name, not the gauntlet draft copy. """ embed = build_gauntlet_recap_embed( make_run(), make_event(), make_main_team(lname="Normal CornBelters"), make_rewards(), ) champion_field = next((f for f in embed.fields if f.name == "Champion"), None) assert champion_field is not None, "Expected a 'Champion' embed field" assert "Normal CornBelters" in champion_field.value def test_champion_field_contains_user_mention_when_gmid_present(self): """When gmid is set, the Champion field must include a Discord user mention. The mention (<@gmid>) creates social validation — the winner is pinged in the channel where they completed the gauntlet. """ embed = build_gauntlet_recap_embed( make_run(), make_event(), make_main_team(gmid=987654321), make_rewards() ) champion_field = next(f for f in embed.fields if f.name == "Champion") assert "<@987654321>" in champion_field.value def test_champion_field_omits_mention_when_gmid_is_none(self): """When gmid is None the embed must not include any mention syntax. Some legacy records or AI teams may not have a Discord user ID. The embed must still be valid without one. """ team = make_main_team() team["gmid"] = None embed = build_gauntlet_recap_embed( make_run(), make_event(), team, make_rewards() ) champion_field = next(f for f in embed.fields if f.name == "Champion") assert "<@" not in champion_field.value def test_final_record_field_present(self): """Final Record field must show wins-losses for the completed run.""" embed = build_gauntlet_recap_embed( make_run(wins=10, losses=1), make_event(), make_main_team(), make_rewards() ) record_field = next((f for f in embed.fields if f.name == "Final Record"), None) assert record_field is not None, "Expected a 'Final Record' field" assert "10" in record_field.value assert "1" in record_field.value def test_prize_distribution_field_present(self): """Prize Distribution field must be present when rewards are provided.""" embed = build_gauntlet_recap_embed( make_run(), make_event(), make_main_team(), make_rewards() ) prize_field = next( (f for f in embed.fields if f.name == "Prize Distribution"), None ) assert prize_field is not None, "Expected a 'Prize Distribution' field" def test_earned_rewards_marked_with_checkmark(self): """Rewards that were earned (wins >= threshold and losses within limit) must be marked with ✅. A 10-1 run earns the 3-win and 7-win milestones but not the 10-0 bonus. """ embed = build_gauntlet_recap_embed( make_run(wins=10, losses=1), make_event(), make_main_team(), make_rewards() ) prize_field = next(f for f in embed.fields if f.name == "Prize Distribution") # 3-win (loss_max=2, losses=1) → earned assert "✅" in prize_field.value def test_unearned_perfect_bonus_marked_correctly(self): """The 10-0 bonus reward must NOT be marked earned when losses > 0.""" embed = build_gauntlet_recap_embed( make_run(wins=10, losses=1), make_event(), make_main_team(), make_rewards() ) prize_field = next(f for f in embed.fields if f.name == "Prize Distribution") # The 10-0 bonus line must be marked ❌ — ineligible, not pending (⬜) lines = prize_field.value.split("\n") bonus_line = next((line for line in lines if "10-0" in line), None) assert bonus_line is not None, "Expected a '10-0' line in prizes" assert "❌" in bonus_line def test_empty_rewards_list_omits_prize_field(self): """When rewards is an empty list the Prize Distribution field must be omitted. Some event types may not have configured rewards; the embed must still be valid and informative without a prize table. """ embed = build_gauntlet_recap_embed( make_run(), make_event(), make_main_team(), [] ) prize_field = next( (f for f in embed.fields if f.name == "Prize Distribution"), None ) assert prize_field is None # --------------------------------------------------------------------------- # Async: post_gauntlet_recap # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_post_gauntlet_recap_sends_embed_on_success(): """When all inputs are valid post_gauntlet_recap sends exactly one embed. This confirms the function wires build_gauntlet_recap_embed → channel.send correctly and doesn't double-post. """ channel = AsyncMock() all_rewards_response = {"rewards": make_rewards()} with patch( "gauntlets.db_get", new_callable=AsyncMock, return_value=all_rewards_response ): await post_gauntlet_recap(make_run(), make_event(), make_main_team(), channel) channel.send.assert_called_once() # Verify an embed was passed (not just plain text) kwargs = channel.send.call_args.kwargs assert "embed" in kwargs assert isinstance(kwargs["embed"], discord.Embed) @pytest.mark.asyncio async def test_post_gauntlet_recap_noop_when_channel_is_none(): """When channel is None post_gauntlet_recap must return without raising. The gauntlet channel may be unavailable (deleted, bot lost permissions, or not set in the record). The completion flow must never fail due to a missing recap channel. """ # No channel.send to assert on — just ensure no exception is raised with patch( "gauntlets.db_get", new_callable=AsyncMock, return_value={"rewards": []} ): try: await post_gauntlet_recap(make_run(), make_event(), make_main_team(), None) except Exception as exc: pytest.fail(f"post_gauntlet_recap raised with None channel: {exc}") @pytest.mark.asyncio async def test_post_gauntlet_recap_nonfatal_when_channel_send_raises(): """A channel.send failure must not propagate out of post_gauntlet_recap. The gauntlet run is already complete when the recap fires; a Discord API error (rate limit, permissions revoked) must not corrupt the game state. """ channel = AsyncMock() channel.send.side_effect = Exception("Discord API error") all_rewards_response = {"rewards": make_rewards()} with patch( "gauntlets.db_get", new_callable=AsyncMock, return_value=all_rewards_response ): try: await post_gauntlet_recap( make_run(), make_event(), make_main_team(), channel ) except Exception as exc: pytest.fail(f"post_gauntlet_recap raised when channel.send failed: {exc}") @pytest.mark.asyncio async def test_post_gauntlet_recap_nonfatal_when_db_get_raises(): """A db_get failure inside post_gauntlet_recap must not propagate. If the rewards endpoint is unavailable the recap silently skips rather than crashing the completion flow. """ channel = AsyncMock() with patch( "gauntlets.db_get", new_callable=AsyncMock, side_effect=Exception("API down") ): try: await post_gauntlet_recap( make_run(), make_event(), make_main_team(), channel ) except Exception as exc: pytest.fail(f"post_gauntlet_recap raised when db_get failed: {exc}") # channel.send should NOT have been called since the error happened before it channel.send.assert_not_called()