diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index e9dd8c0..1774f3c 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -206,6 +206,10 @@ def _add_deaths_field(embed: discord.Embed, rs_chars_start: list[dict], rs_chars if len(prof_names) > 0: deaths_msg = f"{prof_names} [Total:{total_deaths}]" + # Truncate if it would exceed Discord's 1024-char field value limit (2 chars for inline backticks) + if len(deaths_msg) > 1020: + total_suffix = f"... [Total:{total_deaths}]" + deaths_msg = prof_names[: 1020 - len(total_suffix)] + total_suffix embed.add_field(name=gw2_messages.TIMES_YOU_DIED, value=chat_formatting.inline(deaths_msg), inline=False) @@ -230,11 +234,48 @@ def _add_wvw_stats(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: embed.add_field(name=field_name, value=chat_formatting.inline(str(diff))) +def _add_currency_fields(embed: discord.Embed, name: str, lines: list[str]) -> None: + """Add one or more embed fields for a list of currency lines. + + Splits into multiple fields when the value would exceed Discord's + 1024-character field value limit. + """ + max_value_len = 1020 # leave room for backtick wrapping from inline() + chunk: list[str] = [] + chunk_len = 0 + part = 0 + + for line in lines: + # +1 for the newline separator between lines + added_len = len(line) + (1 if chunk else 0) + if chunk and chunk_len + added_len > max_value_len: + part += 1 + field_name = name if part == 1 else f"{name} ({part})" + embed.add_field( + name=field_name, + value=chat_formatting.inline("\n".join(chunk)), + inline=False, + ) + chunk = [] + chunk_len = 0 + chunk.append(line) + chunk_len += added_len + + if chunk: + part += 1 + field_name = name if part == 1 else f"{name} ({part})" + embed.add_field( + name=field_name, + value=chat_formatting.inline("\n".join(chunk)), + inline=False, + ) + + def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: """Add wallet currency fields to embed (all except gold, which has special formatting). Currencies are grouped into "Gained Currencies" and "Lost Currencies" fields - to avoid exceeding Discord's 25-field embed limit. + to avoid exceeding Discord's 25-field and 1024-char field value limits. """ gained_lines = [] lost_lines = [] @@ -253,17 +294,9 @@ def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: di lost_lines.append(f"{diff} {display_name}") if gained_lines: - embed.add_field( - name="Gained Currencies", - value=chat_formatting.inline("\n".join(gained_lines)), - inline=False, - ) + _add_currency_fields(embed, "Gained Currencies", gained_lines) if lost_lines: - embed.add_field( - name="Lost Currencies", - value=chat_formatting.inline("\n".join(lost_lines)), - inline=False, - ) + _add_currency_fields(embed, "Lost Currencies", lost_lines) async def setup(bot): diff --git a/tests/unit/bot/tools/test_bot_utils_extra.py b/tests/unit/bot/tools/test_bot_utils_extra.py index ccec510..12a8382 100644 --- a/tests/unit/bot/tools/test_bot_utils_extra.py +++ b/tests/unit/bot/tools/test_bot_utils_extra.py @@ -8,6 +8,7 @@ sys.modules["ddcDatabases"] = Mock() from src.bot.tools import bot_utils +from src.bot.tools.bot_utils import EmbedPaginatorView class TestSendHelpMsg: @@ -565,3 +566,282 @@ def test_no_system_channel_skips_unreadable_finds_readable(self): result = bot_utils.get_server_system_channel(server) assert result is channel2 + + +class TestSendEmbedDmFallback: + """Test send_embed DM fallback paths (lines 140-142, 155-156).""" + + @pytest.fixture + def mock_ctx(self): + ctx = MagicMock() + ctx.bot = MagicMock() + ctx.bot.settings = {"bot": {"EmbedColor": discord.Color.blue()}} + ctx.message = MagicMock() + ctx.message.author.display_name = "TestUser" + ctx.message.author.display_avatar.url = "https://example.com/avatar.png" + ctx.author = MagicMock() + ctx.author.send = AsyncMock() + ctx.author.display_name = "TestUser" + ctx.author.avatar = MagicMock() + ctx.author.avatar.url = "https://example.com/avatar.png" + ctx.send = AsyncMock() + ctx.channel = MagicMock() # Not a DMChannel + return ctx + + @pytest.mark.asyncio + async def test_dm_send_fails_falls_back_to_channel(self, mock_ctx): + """Test that when DM send fails, embed is sent to channel instead (lines 140-142).""" + embed = discord.Embed(description="Test", color=discord.Color.green()) + mock_ctx.author.send.side_effect = discord.Forbidden(MagicMock(), "Cannot send DM") + + await bot_utils.send_embed(mock_ctx, embed, dm=True) + + # DM failed, should fall back to channel send + mock_ctx.send.assert_called_once_with(embed=embed) + + @pytest.mark.asyncio + async def test_dm_disabled_fallback_also_fails(self, mock_ctx): + """Test that when DM disabled message itself fails to send (lines 155-156).""" + embed = discord.Embed(description="Test", color=discord.Color.green()) + mock_ctx.channel = MagicMock(spec=discord.DMChannel) + # First call (author.send) raises Forbidden + mock_ctx.author.send.side_effect = discord.Forbidden(MagicMock(), "Cannot send DM") + # Fallback channel send also raises + mock_ctx.send.side_effect = discord.Forbidden(MagicMock(), "Cannot send to channel either") + + # Should not raise — the inner except catches it + await bot_utils.send_embed(mock_ctx, embed) + + mock_ctx.bot.log.error.assert_called_once() + + +class TestEmbedPaginatorView: + """Test EmbedPaginatorView class (lines 174-208).""" + + def _make_pages(self, count=3): + return [discord.Embed(title=f"Page {i+1}") for i in range(count)] + + @pytest.mark.asyncio + async def test_init(self): + """Test EmbedPaginatorView initialization (lines 174-179).""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=123) + + assert view.pages is pages + assert view.current_page == 0 + assert view.author_id == 123 + assert view.message is None + assert view.timeout is None + + @pytest.mark.asyncio + async def test_update_buttons_first_page(self): + """Test _update_buttons on first page (lines 182-184).""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=1) + + assert view.previous_button.disabled is True + assert view.next_button.disabled is False + assert view.page_indicator.label == "1/3" + + @pytest.mark.asyncio + async def test_update_buttons_middle_page(self): + """Test _update_buttons on middle page.""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=1) + view.current_page = 1 + view._update_buttons() + + assert view.previous_button.disabled is False + assert view.next_button.disabled is False + assert view.page_indicator.label == "2/3" + + @pytest.mark.asyncio + async def test_update_buttons_last_page(self): + """Test _update_buttons on last page.""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=1) + view.current_page = 2 + view._update_buttons() + + assert view.previous_button.disabled is False + assert view.next_button.disabled is True + assert view.page_indicator.label == "3/3" + + @pytest.mark.asyncio + async def test_next_button_callback(self): + """Test next_button advances page (lines 202-208).""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=42) + interaction = MagicMock() + interaction.user.id = 42 + interaction.response = AsyncMock() + + await view.next_button.callback(interaction) + + assert view.current_page == 1 + interaction.response.edit_message.assert_called_once_with(embed=pages[1], view=view) + + @pytest.mark.asyncio + async def test_previous_button_callback(self): + """Test previous_button goes back (lines 188-194).""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=42) + view.current_page = 2 + view._update_buttons() + interaction = MagicMock() + interaction.user.id = 42 + interaction.response = AsyncMock() + + await view.previous_button.callback(interaction) + + assert view.current_page == 1 + interaction.response.edit_message.assert_called_once_with(embed=pages[1], view=view) + + @pytest.mark.asyncio + async def test_next_button_wrong_user(self): + """Test next_button rejects non-invoker.""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=42) + interaction = MagicMock() + interaction.user.id = 999 + interaction.response = AsyncMock() + + await view.next_button.callback(interaction) + + assert view.current_page == 0 # Unchanged + interaction.response.send_message.assert_called_once() + + @pytest.mark.asyncio + async def test_previous_button_wrong_user(self): + """Test previous_button rejects non-invoker.""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=42) + view.current_page = 1 + interaction = MagicMock() + interaction.user.id = 999 + interaction.response = AsyncMock() + + await view.previous_button.callback(interaction) + + assert view.current_page == 1 # Unchanged + interaction.response.send_message.assert_called_once() + + @pytest.mark.asyncio + async def test_page_indicator_defers(self): + """Test page_indicator just defers (line 198).""" + pages = self._make_pages() + view = EmbedPaginatorView(pages, author_id=1) + interaction = MagicMock() + interaction.response = AsyncMock() + + await view.page_indicator.callback(interaction) + + interaction.response.defer.assert_called_once() + + +class TestSendPaginatedEmbed: + """Test send_paginated_embed function (lines 217-246).""" + + @pytest.fixture + def mock_ctx(self): + ctx = MagicMock() + ctx.bot = MagicMock() + ctx.bot.settings = {"bot": {"EmbedColor": discord.Color.blue()}} + ctx.message = MagicMock() + ctx.message.author.id = 12345 + ctx.message.author.display_name = "TestUser" + ctx.message.author.display_avatar.url = "https://example.com/avatar.png" + ctx.send = AsyncMock() + ctx.channel = MagicMock() + return ctx + + @pytest.mark.asyncio + async def test_no_pagination_under_limit(self, mock_ctx): + """Test embed with <=25 fields is sent directly via send_embed (line 217-219).""" + embed = discord.Embed(color=discord.Color.green()) + embed.set_author(name="Author", icon_url="https://example.com/pic.png") + for i in range(5): + embed.add_field(name=f"Field {i}", value=f"Value {i}") + + with patch("src.bot.tools.bot_utils.send_embed") as mock_send: + await bot_utils.send_paginated_embed(mock_ctx, embed) + mock_send.assert_called_once_with(mock_ctx, embed) + + @pytest.mark.asyncio + async def test_pagination_splits_fields(self, mock_ctx): + """Test embed with >25 fields is split into pages (lines 221-246).""" + embed = discord.Embed(color=discord.Color.green(), description="Test desc") + embed.set_author(name="Author", icon_url="https://example.com/pic.png") + embed.set_thumbnail(url="https://example.com/thumb.png") + for i in range(30): + embed.add_field(name=f"Field {i}", value=f"Value {i}") + + await bot_utils.send_paginated_embed(mock_ctx, embed) + + mock_ctx.send.assert_called_once() + call_kwargs = mock_ctx.send.call_args[1] + sent_embed = call_kwargs["embed"] + assert len(sent_embed.fields) == 25 # First page + assert isinstance(call_kwargs["view"], EmbedPaginatorView) + + view = call_kwargs["view"] + assert len(view.pages) == 2 + assert len(view.pages[0].fields) == 25 + assert len(view.pages[1].fields) == 5 + # Check properties preserved + assert view.pages[0].description == "Test desc" + assert view.pages[0].author.name == "Author" + assert view.pages[0].thumbnail.url == "https://example.com/thumb.png" + assert "Page 1/2" in view.pages[0].footer.text + + @pytest.mark.asyncio + async def test_pagination_exactly_25_no_split(self, mock_ctx): + """Test embed with exactly 25 fields is sent directly.""" + embed = discord.Embed(color=discord.Color.green()) + for i in range(25): + embed.add_field(name=f"Field {i}", value=f"Value {i}") + + with patch("src.bot.tools.bot_utils.send_embed") as mock_send: + await bot_utils.send_paginated_embed(mock_ctx, embed) + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_pagination_single_page_after_split(self, mock_ctx): + """Test that if split results in 1 page, send_embed is used (line 240-242).""" + embed = discord.Embed(color=discord.Color.green()) + for i in range(26): + embed.add_field(name=f"Field {i}", value=f"Value {i}") + + # max_fields=30 means 26 fields fit in one page + with patch("src.bot.tools.bot_utils.send_embed") as mock_send: + await bot_utils.send_paginated_embed(mock_ctx, embed, max_fields=30) + mock_send.assert_called_once() + + @pytest.mark.asyncio + async def test_pagination_no_color_uses_settings(self, mock_ctx): + """Test that embed without color gets it from settings.""" + embed = discord.Embed() + for i in range(30): + embed.add_field(name=f"Field {i}", value=f"Value {i}") + + await bot_utils.send_paginated_embed(mock_ctx, embed) + + call_kwargs = mock_ctx.send.call_args[1] + view = call_kwargs["view"] + assert view.pages[0].color == discord.Color.blue() + + @pytest.mark.asyncio + async def test_pagination_stores_message(self, mock_ctx): + """Test that view.message is set after sending.""" + embed = discord.Embed(color=discord.Color.green()) + for i in range(30): + embed.add_field(name=f"Field {i}", value=f"Value {i}") + + sent_msg = MagicMock() + mock_ctx.send.return_value = sent_msg + + await bot_utils.send_paginated_embed(mock_ctx, embed) + + call_kwargs = mock_ctx.send.call_args[1] + view = call_kwargs["view"] + assert view.message is sent_msg diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index ca05825..d071acf 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -4,6 +4,7 @@ import pytest from src.gw2.cogs.sessions import ( GW2Session, + _add_currency_fields, _add_deaths_field, _add_gold_field, _add_wallet_currency_fields, @@ -937,6 +938,40 @@ def test_gained_and_lost_separate_fields(self): assert "Lost Currencies" in field_names +class TestAddCurrencyFields: + """Test the _add_currency_fields helper for splitting long values.""" + + def test_splits_when_exceeding_limit(self): + """Test that lines are split across fields when they exceed 1024 chars.""" + embed = discord.Embed() + # 40 lines * ~45 chars each ≈ 1800+ chars → must split + lines = [f"+{i * 100000} Testimony of Some Currency Name {i}" for i in range(1, 41)] + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_currency_fields(embed, "Gained Currencies", lines) + assert len(embed.fields) >= 2 + assert embed.fields[0].name == "Gained Currencies" + assert embed.fields[1].name == "Gained Currencies (2)" + + def test_single_field_when_short(self): + """Test that a small list produces exactly one field.""" + embed = discord.Embed() + lines = ["+100 Karma", "+50 Laurels"] + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_currency_fields(embed, "Gained Currencies", lines) + assert len(embed.fields) == 1 + assert embed.fields[0].name == "Gained Currencies" + + def test_no_field_value_exceeds_1024(self): + """Test that no individual field value exceeds Discord's 1024-char limit.""" + embed = discord.Embed() + # Simulate worst case: all 77 non-gold currencies changed + lines = [f"+{i * 99999} Testimony of Some Long Currency Name {i}" for i in range(1, 78)] + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_currency_fields(embed, "Test", lines) + for field in embed.fields: + assert len(field.value) <= 1024 + + class TestSessionSetup: """Test cases for session cog setup."""