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