- 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>
375 lines
14 KiB
Python
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
|
|
)
|