paper-dynasty-discord/tests/test_gauntlet_recap.py
Cal Corum cc72827dad
All checks were successful
Ruff Lint / lint (pull_request) Successful in 19s
fix(gauntlet): fix loss_max=0 falsy-zero trap in recap marker logic
`(loss_max or 99)` treats `loss_max=0` as 99, so 10-1 runs showed 
instead of  for perfect-run rewards. Fix uses explicit None check.
Tighten test to assert  presence rather than just absence of .

Addresses review feedback on PR #165.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:32:10 -05:00

316 lines
12 KiB
Python

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