paper-dynasty-discord/tests/scouting/test_scouting_helpers.py
Cal Corum d538c679c3 refactor: Consolidate scouting utilities, add test suite, use Discord timestamps
- Consolidate SCOUT_TOKENS_PER_DAY and get_scout_tokens_used() into
  helpers/scouting.py (was duplicated across 3 files)
- Add midnight_timestamp() utility to helpers/utils.py
- Remove _build_scouted_ids() wrapper, use self.claims directly
- Fix build_scout_embed return type annotation
- Use Discord <t:UNIX:R> relative timestamps for scout window countdown
- Add 66-test suite covering helpers, ScoutView, and cog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:04:53 +00:00

375 lines
14 KiB
Python

"""Tests for helpers/scouting.py — embed builders and scout opportunity creation.
Covers the pure functions (_build_card_lines, build_scout_embed,
build_scouted_card_list) and the async create_scout_opportunity flow.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import discord
from helpers.scouting import (
_build_card_lines,
build_scout_embed,
build_scouted_card_list,
create_scout_opportunity,
RARITY_SYMBOLS,
)
# ---------------------------------------------------------------------------
# _build_card_lines
# ---------------------------------------------------------------------------
class TestBuildCardLines:
"""Tests for the shuffled card line builder."""
def test_returns_correct_count(self, sample_cards):
"""Should produce one line per card in the pack."""
lines = _build_card_lines(sample_cards)
assert len(lines) == len(sample_cards)
def test_each_line_contains_player_id(self, sample_cards):
"""Each tuple's first element should be the player_id from the card."""
lines = _build_card_lines(sample_cards)
ids = {pid for pid, _ in lines}
expected_ids = {c["player"]["player_id"] for c in sample_cards}
assert ids == expected_ids
def test_each_line_contains_player_name(self, sample_cards):
"""The display string should include the player's name."""
lines = _build_card_lines(sample_cards)
for pid, display in lines:
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
assert card["player"]["p_name"] in display
def test_each_line_contains_rarity_name(self, sample_cards):
"""The display string should include the rarity tier name."""
lines = _build_card_lines(sample_cards)
for pid, display in lines:
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
assert card["player"]["rarity"]["name"] in display
def test_rarity_symbol_present(self, sample_cards):
"""Each line should start with the appropriate rarity emoji."""
lines = _build_card_lines(sample_cards)
for pid, display in lines:
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
rarity_val = card["player"]["rarity"]["value"]
expected_symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab")
assert display.startswith(expected_symbol)
def test_output_is_shuffled(self, sample_cards):
"""Over many runs, the order should not always match the input order.
We run 20 iterations — if it comes out sorted every time, the shuffle
is broken (probability ~1/20! per run, effectively zero).
"""
input_order = [c["player"]["player_id"] for c in sample_cards]
saw_different = False
for _ in range(20):
lines = _build_card_lines(sample_cards)
output_order = [pid for pid, _ in lines]
if output_order != input_order:
saw_different = True
break
assert saw_different, "Card lines were never shuffled across 20 runs"
def test_empty_cards(self):
"""Empty input should produce an empty list."""
assert _build_card_lines([]) == []
def test_unknown_rarity_uses_fallback_symbol(self):
"""A rarity value not in RARITY_SYMBOLS should get the black circle fallback."""
card = {
"id": 99,
"player": {
"player_id": 999,
"p_name": "Unknown Rarity",
"rarity": {"name": "Legendary", "value": 99, "color": "gold"},
},
}
lines = _build_card_lines([card])
assert lines[0][1].startswith("\u26ab") # black circle fallback
# ---------------------------------------------------------------------------
# build_scout_embed
# ---------------------------------------------------------------------------
class TestBuildScoutEmbed:
"""Tests for the embed builder shown above scout buttons."""
def test_returns_embed_and_card_lines(self, opener_team, sample_cards):
"""Should return a (discord.Embed, list) tuple."""
embed, card_lines = build_scout_embed(opener_team, sample_cards)
assert isinstance(embed, discord.Embed)
assert isinstance(card_lines, list)
assert len(card_lines) == len(sample_cards)
def test_embed_description_contains_team_name(self, opener_team, sample_cards):
"""The embed body should mention the opener's team name."""
embed, _ = build_scout_embed(opener_team, sample_cards)
assert opener_team["lname"] in embed.description
def test_embed_description_contains_all_player_names(
self, opener_team, sample_cards
):
"""Every player name from the pack should appear in the embed."""
embed, _ = build_scout_embed(opener_team, sample_cards)
for card in sample_cards:
assert card["player"]["p_name"] in embed.description
def test_embed_mentions_token_cost(self, opener_team, sample_cards):
"""The embed should tell users about the scout token cost."""
embed, _ = build_scout_embed(opener_team, sample_cards)
assert "Scout Token" in embed.description
def test_embed_mentions_time_limit(self, opener_team, sample_cards):
"""The embed should mention the 30-minute window."""
embed, _ = build_scout_embed(opener_team, sample_cards)
assert "30 minutes" in embed.description
def test_prebuilt_card_lines_are_reused(self, opener_team, sample_cards):
"""When card_lines are passed in, they should be reused (not rebuilt)."""
prebuilt = [(101, "Custom Line 1"), (102, "Custom Line 2")]
embed, returned_lines = build_scout_embed(
opener_team, sample_cards, card_lines=prebuilt
)
assert returned_lines is prebuilt
assert "Custom Line 1" in embed.description
assert "Custom Line 2" in embed.description
# ---------------------------------------------------------------------------
# build_scouted_card_list
# ---------------------------------------------------------------------------
class TestBuildScoutedCardList:
"""Tests for the card list formatter that marks scouted cards."""
def test_no_scouts_returns_plain_lines(self):
"""With no scouts, output should match the raw card lines."""
card_lines = [
(101, "\U0001f7e3 MVP — Mike Trout"),
(102, "\U0001f535 All-Star — Juan Soto"),
]
result = build_scouted_card_list(card_lines, {})
assert result == "\U0001f7e3 MVP — Mike Trout\n\U0001f535 All-Star — Juan Soto"
def test_single_scout_shows_team_name(self):
"""A card scouted once should show a checkmark and the team name."""
card_lines = [
(101, "\U0001f7e3 MVP — Mike Trout"),
(102, "\U0001f535 All-Star — Juan Soto"),
]
scouted = {101: ["Scouting Squad"]}
result = build_scouted_card_list(card_lines, scouted)
assert "\u2714\ufe0f" in result # checkmark
assert "*Scouting Squad*" in result
# Unscouted card should appear plain
lines = result.split("\n")
assert "\u2714" not in lines[1]
def test_multiple_scouts_shows_count_and_names(self):
"""A card scouted multiple times should show the count and all team names."""
card_lines = [(101, "\U0001f7e3 MVP — Mike Trout")]
scouted = {101: ["Team A", "Team B", "Team C"]}
result = build_scouted_card_list(card_lines, scouted)
assert "x3" in result
assert "*Team A*" in result
assert "*Team B*" in result
assert "*Team C*" in result
def test_mixed_scouted_and_unscouted(self):
"""Only scouted cards should have marks; unscouted cards stay plain."""
card_lines = [
(101, "Line A"),
(102, "Line B"),
(103, "Line C"),
]
scouted = {102: ["Some Team"]}
result = build_scouted_card_list(card_lines, scouted)
lines = result.split("\n")
assert "\u2714" not in lines[0]
assert "\u2714" in lines[1]
assert "\u2714" not in lines[2]
def test_empty_input(self):
"""Empty card lines should produce an empty string."""
assert build_scouted_card_list([], {}) == ""
def test_two_scouts_shows_count(self):
"""Two scouts on the same card should show x2."""
card_lines = [(101, "Line A")]
scouted = {101: ["Team X", "Team Y"]}
result = build_scouted_card_list(card_lines, scouted)
assert "x2" in result
# ---------------------------------------------------------------------------
# create_scout_opportunity
# ---------------------------------------------------------------------------
class TestCreateScoutOpportunity:
"""Tests for the async scout opportunity creation flow."""
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_posts_to_api_and_sends_message(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""Should POST to scout_opportunities and send a message to the channel."""
mock_db_post.return_value = {"id": 42}
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
# API was called to create the opportunity
mock_db_post.assert_called_once()
call_args = mock_db_post.call_args
assert call_args[0][0] == "scout_opportunities"
assert call_args[1]["payload"]["opener_team_id"] == opener_team["id"]
# Message was sent to the channel
mock_channel.send.assert_called_once()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_skips_wrong_channel(
self, mock_db_post, sample_cards, opener_team, mock_bot
):
"""Should silently return when the channel is not #pack-openings."""
channel = AsyncMock(spec=discord.TextChannel)
channel.name = "general"
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, channel, opener_user, context
)
mock_db_post.assert_not_called()
channel.send.assert_not_called()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_skips_empty_pack(
self, mock_db_post, opener_team, mock_channel, mock_bot
):
"""Should silently return when pack_cards is empty."""
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
[], opener_team, mock_channel, opener_user, context
)
mock_db_post.assert_not_called()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_skips_none_channel(
self, mock_db_post, sample_cards, opener_team, mock_bot
):
"""Should handle None channel without crashing."""
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, None, opener_user, context
)
mock_db_post.assert_not_called()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_api_failure_does_not_raise(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""Scout creation failure must never crash the pack opening flow."""
mock_db_post.side_effect = Exception("API down")
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
# Should not raise
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_channel_send_failure_does_not_raise(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""If the channel.send fails, it should be caught gracefully."""
mock_db_post.return_value = {"id": 42}
mock_channel.send.side_effect = discord.HTTPException(
Mock(status=500), "Server error"
)
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
# Should not raise
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_context_client_fallback(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""When context.bot is None, should fall back to context.client for the bot ref."""
mock_db_post.return_value = {"id": 42}
opener_user = Mock()
opener_user.id = 99999
context = Mock(spec=[]) # empty spec — no .bot attribute
context.client = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
mock_channel.send.assert_called_once()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_view_message_is_assigned(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""The message returned by channel.send should be assigned to view.message.
This linkage is required for update_message and on_timeout to work.
"""
mock_db_post.return_value = {"id": 42}
sent_msg = AsyncMock(spec=discord.Message)
mock_channel.send.return_value = sent_msg
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)