From 6d5540acaac8e32e60900386d38f754eb45ac28f Mon Sep 17 00:00:00 2001 From: Lev Bernstein Date: Sat, 6 Dec 2025 09:20:23 -0500 Subject: [PATCH 1/2] Fix: Handle latest Brawlhalla API changes --- bb_test.py | 1135 ++++++++++++++++++++++++------------ brawl.py | 54 +- resources/requirements.txt | 21 +- 3 files changed, 790 insertions(+), 420 deletions(-) diff --git a/bb_test.py b/bb_test.py index 2705b30..c64495c 100644 --- a/bb_test.py +++ b/bb_test.py @@ -24,12 +24,13 @@ import os import subprocess import sys +import time from collections import deque from collections.abc import AsyncIterator from copy import copy from datetime import datetime from pathlib import Path -from typing import Any, Final, Literal, Self, TypedDict, overload, override +from typing import Any, Final, overload, override from urllib.parse import quote_plus import aiofiles @@ -44,11 +45,11 @@ from flake8.api import legacy as flake8 from mypy.api import run as mypy from nextcord.ext import commands -from nextcord.types.channel import TextChannel as TextChannelPayload -from nextcord.types.emoji import Emoji as EmojiPayload -from nextcord.types.emoji import PartialEmoji as PartialEmojiPayload -from nextcord.types.message import Message as MessagePayload -from nextcord.types.message import Reaction as ReactionPayload +from nextcord.types import channel as channel_payloads +from nextcord.types import emoji as emoji_payloads +from nextcord.types import message as message_payloads +from nextcord.types.embed import Embed as EmbedPayload +from nextcord.types.role import Role as RolePayload from nextcord.types.user import User as UserPayload from pytest_httpx import HTTPXMock @@ -97,7 +98,7 @@ SnowflakeTime = datetime | nextcord.abc.Snowflake -StyleGuide = flake8.get_style_guide(ignore=["A003", "W191", "W503"]) +StyleGuide = flake8.get_style_guide(ignore=["W191", "W503"]) dotenv.load_dotenv(".env") BrawlKey: str | None = os.environ.get("BRAWLKEY") @@ -133,13 +134,15 @@ def valid_image_url(resp: httpx.Response) -> bool: return response_ok(resp) and resp.headers["content-type"] in ImageTypes -async def latest_message(ch: MessageableChannel) -> nextcord.Message | None: +async def latest_message( + ch: nextcord.abc.Messageable, +) -> nextcord.Message | None: """ - Get the most recent message from a channel's history. + Get the most recent message from a messageable's history. Args: - ch (MessageableChannel): The channel from which the most recent message - will be pulled. + ch (nextcord.abc.Messageable): The messageable from which the most + recent message will be pulled. Returns: nextcord.Message | None: The most recent message, if one exists; @@ -174,54 +177,75 @@ def __init__( self._global_over.set() @override - async def create_role( # type: ignore[override] + async def create_role( self, guild_id: nextcord.types.snowflake.Snowflake, reason: str | None = None, + auth: str | None = None, + retry_request: bool = True, **fields: Any, - ) -> dict[str, Any]: + ) -> RolePayload: data = dict(fields) - data["id"] = guild_id if reason: logger.info("Role creation reason: %s", reason) - return data + return RolePayload( + id=guild_id, + name=data["name"], + color=data.get("color", 0), + hoist=data.get("hoist", False), + position=data.get("position", 0), + permissions=data.get("permissions", 0), + managed=False, + mentionable=data.get("mentionable", True), + flags=0, + ) @override - async def send_message( # type: ignore[override] + async def send_message( self, channel_id: nextcord.types.snowflake.Snowflake, - content: str | None = None, + content: str | None, *, tts: bool = False, - embed: nextcord.Embed | None = None, - embeds: list[nextcord.Embed] | None = None, + embed: EmbedPayload | None = None, + embeds: list[EmbedPayload] | None = None, nonce: int | str | None = None, - allowed_mentions: nextcord.AllowedMentions | None = None, - message_reference: nextcord.MessageReference | None = None, + allowed_mentions: message_payloads.AllowedMentions | None = None, + message_reference: message_payloads.MessageReference | None = None, stickers: list[int] | None = None, - components: list[nextcord.Component] | None = None, + components: list[nextcord.types.components.Component] | None = None, flags: int | None = None, auth: str | None = None, retry_request: bool = True, - ) -> dict[str, Any]: - return { - "attachments": [], - "edited_timestamp": None, - "type": nextcord.Message, - "pinned": False, - "mention_everyone": content and "@everyone" in content, - "tts": tts, - "author": MockUser(), - "content": content or "", - "nonce": nonce, - "allowed_mentions": allowed_mentions, - "message_reference": message_reference, - "components": components, - "stickers": stickers, - "flags": flags, - "channel_id": channel_id, - "embeds": [embed] if embed else embeds or [], - } + ) -> message_payloads.Message: + user = MockUser() + up = UserPayload( + id=user.id, + username=user.name, + discriminator=user.discriminator, + avatar=user._avatar, + ) + mention_everyone = (content is not None and ("@everyone" in content)) + return message_payloads.Message( + id=0, + channel_id=channel_id, + author=up, + content=content or "", + timestamp=str(datetime.now(misc.TimeZone)), + edited_timestamp=None, + tts=tts, + mention_everyone=mention_everyone, + mentions=[], + mention_roles=[], + attachments=[], + embeds=[embed] if embed else embeds or [], + pinned=False, + type=0, + nonce=nonce or 0, + message_reference=message_payloads.MessageReference(type=0), + flags=flags or 0, + components=components or [], + ) @override async def leave_guild( @@ -261,10 +285,6 @@ def __init__( (after is not None) if oldest_first is None else oldest_first ) - @override - def __aiter__(self) -> Self: - return self - @override async def __anext__(self) -> nextcord.Message: if not hasattr(self, "channel"): @@ -279,10 +299,10 @@ async def __anext__(self) -> nextcord.Message: if self.reverse else self.channel.messages ) - final = data[self.yielded] - assert isinstance(final, nextcord.Message) + message = data[self.yielded] + assert isinstance(message, nextcord.Message) self.yielded += 1 - return final + return message raise StopAsyncIteration @@ -408,7 +428,10 @@ def __init__(self, message_number: int = 0) -> None: @override def create_message( - self, *, channel: MessageableChannel, data: MessagePayload, + self, + *, + channel: MessageableChannel, + data: message_payloads.Message, ) -> nextcord.Message: data["id"] = self.user.id if self.user else "0" data["components"] = [] @@ -531,12 +554,14 @@ def __init__( self.allowed_mentions = nextcord.AllowedMentions(everyone=True) self.user = user self.last_message_id = message_number - d = deque(messages if messages else []) - self._messages: deque[nextcord.Message] = d + self._messages: deque[nextcord.Message] = deque(messages or []) @override def create_message( - self, *, channel: MessageableChannel, data: MessagePayload, + self, + *, + channel: MessageableChannel, + data: message_payloads.Message, ) -> nextcord.Message: data["id"] = self.last_message_id data["components"] = [] @@ -562,14 +587,6 @@ def store_user(self, data: UserPayload) -> nextcord.User: assert isinstance(stored_user, nextcord.User) return stored_user - class MockPermPayload(TypedDict): - """Drop-in replacement for PermissionOverwritePayload.""" - - id: nextcord.types.snowflake.Snowflake - type: Literal[0, 1] - allow: str - deny: str - @override def __init__( self, @@ -586,11 +603,9 @@ def __init__( self.topic = None self.category_id = 0 self.guild = guild or nextcord.utils.MISSING - self._type: Literal[0] = 0 clean_messages = messages or [] self._state = self.MockChannelState( - message_number=len(clean_messages), - messages=clean_messages, + message_number=len(clean_messages), messages=clean_messages, ) self.assign_channel_to_guild(self.guild) self._overwrites = [] @@ -625,37 +640,32 @@ async def set_permissions( reason: str | None = None, **kwargs: Any, ) -> None: - if reason: - logger.info("Permissions set reason: %s", reason) overwrite = kwargs.pop("overwrite", None) permissions: dict[str, bool] = kwargs if overwrite is None and len(permissions) != 0: overwrite = nextcord.PermissionOverwrite(**permissions) if overwrite is not None: allow, deny = overwrite.pair() - perm_type: Literal[0, 1] = ( - 0 if isinstance(target, nextcord.Role) else 1 - ) - payload = self.MockPermPayload( + permission_payload = channel_payloads.PermissionOverwrite( id=target.id, + type=0 if isinstance(target, nextcord.Role) else 1, allow=allow.value, deny=deny.value, - type=perm_type, ) - guild_payload = TextChannelPayload( + channel_payload = channel_payloads.TextChannel( guild_id=self.guild.id, position=self.position, - permission_overwrites=[payload], + permission_overwrites=[permission_payload], nsfw=self.nsfw, id=self.id, name=self.name, rate_limit_per_user=0, default_auto_archive_duration=60, default_thread_rate_limit_per_user=0, - type=self._type, + type=0, parent_id=None, ) - self._fill_overwrites(guild_payload) + self._fill_overwrites(channel_payload) logger.info( "Setting perms on channel: %s for target: %s, reason: %s", self.id, @@ -737,7 +747,7 @@ def __init__( @override def get_reaction_emoji( - self, data: EmojiPayload, + self, data: emoji_payloads.Emoji, ) -> nextcord.emoji.Emoji: return MockEmoji(self.guild, data, MockMessage()) @@ -785,7 +795,7 @@ def get_mock_reaction_payload( *, me: bool = False, count: int = 1, - ) -> ReactionPayload: + ) -> message_payloads.Reaction: """ Create the payload for a Reaction. @@ -797,14 +807,14 @@ def get_mock_reaction_payload( nextcord.Message (default is 1) Returns: - ReactionPayload: The TypedDict that will be used to create a new - Reaction, constructed with the provided arguments. + message_payloads.Reaction: The TypedDict that will be used to + create a new Reaction, constructed with the provided arguments. """ - return ReactionPayload( + return message_payloads.Reaction( me=me, count=count, - emoji=PartialEmojiPayload(id=emoji_id, name=name), + emoji=emoji_payloads.PartialEmoji(id=emoji_id, name=name), ) @@ -928,15 +938,13 @@ def me(self) -> nextcord.Member: else self._state.user ) assert isinstance(user, nextcord.User) - return MockMember( - user, - guild=self, - perms=( - user.guild_permissions - if hasattr(user, "guild_permissions") - else None - ), + perms = ( + user.guild_permissions + if hasattr(user, "guild_permissions") + else None ) + assert isinstance(perms, nextcord.Permissions | None) + return MockMember(user, guild=self, perms=perms) @overload @override @@ -1059,40 +1067,30 @@ def __init__( self.message_count = 0 self._type = nextcord.enums.ChannelType(0) self.auto_archive_duration = 10080 - self.me = me # type: ignore[assignment] + self.bot_user = Bot.BeardlessBot.user + assert isinstance(self.bot_user, MockBot.MockClientUser) + self.me = me or MockMember( # type: ignore[assignment] + self.bot_user.base_user, guild=self.guild, + ) self._members = copy(channel.guild._members) # type: ignore[arg-type] - if self.me and Bot.BeardlessBot.user is not None and not any( - user.id == Bot.BeardlessBot.user.id for user in self.members - ): - assert isinstance(Bot.BeardlessBot.user, MockBot.MockClientUser) - self._members[ - len(self.members) - ] = MockMember( # type: ignore[assignment] - Bot.BeardlessBot.user.base_user, guild=self.guild, - ) - self.member_count = len(self.members) + self._safe_join() @override async def join(self) -> None: - # TODO: switch this to using self.me - assert isinstance( - Bot.BeardlessBot.user, MockBot.MockClientUser, - ) - if not any( - user.id == Bot.BeardlessBot.user.id for user in self.members - ): - if not any( - user.id == Bot.BeardlessBot.user.id - for user in self.guild.members - ): - self.guild._members[ - len(self.guild.members) - ] = MockMember( - Bot.BeardlessBot.user.base_user, guild=self.guild, - ) - self._members[len(self.members)] = MockMember( # type: ignore[assignment] - Bot.BeardlessBot.user.base_user, guild=self.guild, - ) + self._safe_join() + + def _safe_join(self) -> None: + """Non-async version of join for testing.""" + assert isinstance(self.bot_user, MockBot.MockClientUser) + if not any(user.id == self.bot_user.id for user in self.members): + self._members[ + len(self.members) + ] = self.me # type: ignore[assignment] + if not any(user.id == self.bot_user.id for user in self.guild.members): + self.guild._members[ + len(self.guild.members) + ] = self.me # type: ignore[assignment] + self.member_count = len(self.members) class MockContext(misc.BotContext): @@ -1117,7 +1115,10 @@ def __init__( @override def create_message( - self, *, channel: MessageableChannel, data: MessagePayload, + self, + *, + channel: MessageableChannel, + data: message_payloads.Message, ) -> nextcord.Message: assert isinstance( self.channel._state, MockChannel.MockChannelState, @@ -1184,6 +1185,7 @@ def history( oldest_first: bool | None = False, ) -> AsyncIterator[nextcord.Message]: assert hasattr(self._state, "channel") + assert isinstance(self._state.channel, nextcord.abc.Messageable) return MockHistoryIterator( self._state.channel, limit, @@ -1268,6 +1270,9 @@ async def edit( @override def __init__(self, bot: commands.Bot) -> None: self._connection = bot._connection + self.activity = nextcord.CustomActivity( + name="Try !blackjack and !flip", + ) self._connection.user = self.MockClientUser() self.command_prefix = bot.command_prefix self.case_insensitive = bot.case_insensitive @@ -1290,8 +1295,10 @@ async def change_presence( activity: nextcord.BaseActivity | None = None, status: str | None = None, ) -> None: - assert isinstance(activity, nextcord.types.activity.Activity | None) - self._connection._activity = activity + if activity is None: + self._connection._activity = None + else: + self._connection._activity = activity.to_dict() self.status = status or nextcord.Status.online @@ -1302,7 +1309,7 @@ class MockEmoji(nextcord.emoji.Emoji): def __init__( self, guild: nextcord.Guild, - data: EmojiPayload, + data: emoji_payloads.Emoji, state_message: nextcord.Message | None = None, ) -> None: self.guild_id = guild.id @@ -1310,7 +1317,7 @@ def __init__( self._from_data(data) -# Run code quality tests with pytest -vvk quality +# Run suite of code quality tests with pytest -vvk quality def test_with_ruff_for_code_quality() -> None: """ Test against ruff executable's output to ensure compliance. @@ -1387,8 +1394,7 @@ def test_no_spelling_errors_with_codespell_for_code_quality() -> None: def test_no_out_of_date_requirements_for_code_quality() -> None: - # Assumes requirements.txt is of the format foo==bar, rather than - # foo>=bar or foo<=bar. + """Assumes requirements.txt is of the format foo==bar, not >= or <=.""" pip_list_outdated = json.loads(subprocess.run( [sys.executable, "-m", "pip", "list", "--outdated", "--format=json"], capture_output=True, @@ -1435,6 +1441,20 @@ async def test_mock_guild_chunked() -> None: assert guild.chunked +@MarkAsync +async def test_msg_delete_waits_for_delay() -> None: + ch = MockChannel() + msg = MockMessage(content="foo", channel=ch, guild=MockGuild()) + assert len([i async for i in ch.history()]) == 1 + + start_time = time.perf_counter() + await msg.delete(delay=0.25) + end_time = time.perf_counter() + assert round(end_time - start_time, 2) == 0.25 + + assert len([i async for i in ch.history()]) == 0 + + @MarkAsync async def test_on_command_error(caplog: pytest.LogCaptureFixture) -> None: author = MockMember(admin_powers=True) @@ -1471,7 +1491,7 @@ async def test_on_command_error(caplog: pytest.LogCaptureFixture) -> None: guild, commands.errors.UnexpectedQuoteError, ) - m = await latest_message(ctx.channel) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].title == "Careful with quotation marks!" @@ -1486,10 +1506,16 @@ async def test_role_creation_reason_logged( assert len(g.roles) == 1 caplog.set_level(logging.INFO) await g.create_role(name="Spamalot", reason="FooBarSpam") + assert len(caplog.records) == 1 assert caplog.records[0].getMessage() == "Role creation reason: FooBarSpam" assert len(g.roles) == 2 assert g.roles[1].name == "Spamalot" + await g.create_role(name="Foobar") + assert len(caplog.records) == 1 + assert len(g.roles) == 3 + assert g.roles[2].name == "Foobar" + @MarkAsync async def test_create_muted_role(caplog: pytest.LogCaptureFixture) -> None: @@ -1507,9 +1533,10 @@ async def test_create_muted_role(caplog: pytest.LogCaptureFixture) -> None: assert caplog.records[0].getMessage() == ( "Role creation reason: BB Muted Role" ) - assert caplog.records[1].getMessage() == ( - "Permissions set reason: Preventing Muted" - " users from chatting in this channel" + assert caplog.records[1].args == ( + g.channels[0].id, + role.id, + "Preventing Muted users from chatting in this channel", ) role = MockRole("Muted", 500) @@ -1527,6 +1554,23 @@ def test_bot_avatar_correct_on_creation() -> None: ) +@MarkAsync +async def test_bot_change_presence() -> None: + Bot.BeardlessBot = MockBot(Bot.BeardlessBot) + assert Bot.BeardlessBot.activity is not None + assert Bot.BeardlessBot.activity.name == "Try !blackjack and !flip" + assert Bot.BeardlessBot.status == nextcord.Status.online + await Bot.BeardlessBot.change_presence( + activity=None, status=nextcord.Status.dnd, + ) + assert Bot.BeardlessBot.activity is None + assert Bot.BeardlessBot.status == nextcord.Status.dnd + + await Bot.BeardlessBot.change_presence(activity=nextcord.Game("Foo")) + assert Bot.BeardlessBot.activity is not None + assert Bot.BeardlessBot.activity.name == "Foo" + + @MarkAsync async def test_on_ready(caplog: pytest.LogCaptureFixture) -> None: Bot.BeardlessBot = MockBot(Bot.BeardlessBot) @@ -1578,12 +1622,8 @@ def mock_raise_file_not_found_error(filepath: str, mode: str) -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr(MockBot.MockClientUser, "edit", mock_raise_http_exception) await Bot.on_ready() - assert caplog.records[0].msg == ( - "Failed to update avatar!" - ) - assert caplog.records[1].msg == ( - "Bot is in no servers! Add it to a server." - ) + assert caplog.records[0].msg == "Failed to update avatar!" + assert caplog.records[1].msg == "Bot is in no servers! Add it to a server." with pytest.MonkeyPatch.context() as mp: mp.setattr("aiofiles.open", mock_raise_file_not_found_error) @@ -1591,9 +1631,7 @@ def mock_raise_file_not_found_error(filepath: str, mode: str) -> None: assert caplog.records[2].msg == ( "Avatar file not found! Check your directory structure." ) - assert caplog.records[3].msg == ( - "Bot is in no servers! Add it to a server." - ) + assert caplog.records[3].msg == "Bot is in no servers! Add it to a server." @MarkAsync @@ -1659,6 +1697,38 @@ def test_content_check_custom_limit() -> None: assert misc.content_check(MockMessage(""), 1025) == "**Embed**" +@MarkAsync +async def test_no_log_messages_sent_when_no_log_channel_defined() -> None: + ch = MockChannel(channel_id=0, name="bar") + guild = MockGuild(channels=[ch]) + m = MockMember(guild=guild) + message = MockMessage(content="foo", guild=guild) + assert await Bot.on_message_delete(message) is None + assert await Bot.on_member_join(m) is None + assert await Bot.on_member_remove(m) is None + assert await Bot.on_message_edit( + message, MockMessage(content="spam", guild=guild), + ) is None + assert await Bot.on_member_ban(guild, m) is None + assert await Bot.on_member_unban(guild, m) is None + assert await Bot.on_member_update(m, m) is None + assert await Bot.on_bulk_message_delete([message]) is None + assert await Bot.on_guild_channel_delete(ch) is None + assert await Bot.on_guild_channel_create(ch) is None + + reaction = nextcord.Reaction( + message=message, data=MockMessage.get_mock_reaction_payload("foo"), + ) + assert await Bot.on_reaction_clear(message, [reaction]) is None + + th = MockThread(parent=ch, me=None, name="Foo") + assert await Bot.on_thread_join(th) is None + th.me = None + assert await Bot.on_thread_join(th) is None + assert await Bot.on_thread_delete(th) is None + assert await Bot.on_thread_update(th, th) is None + + @MarkAsync async def test_on_message_delete() -> None: ch = MockChannel(name=misc.LogChannelName) @@ -1676,8 +1746,6 @@ async def test_on_message_delete() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_message_delete(MockMessage()) is None - @MarkAsync async def test_on_bulk_message_delete() -> None: @@ -1701,16 +1769,11 @@ async def test_on_bulk_message_delete() -> None: assert emb.description == log.description assert log.description == "Purged 99+ messages in <#123456789>." - assert await Bot.on_bulk_message_delete( - [MockMessage(guild=MockGuild())], - ) is None - @MarkAsync async def test_on_reaction_clear() -> None: ch = MockChannel(channel_id=0, name=misc.LogChannelName) guild = MockGuild(channels=[ch]) - ch.guild = guild reaction = nextcord.Reaction( message=MockMessage(), data=MockMessage.get_mock_reaction_payload("foo"), @@ -1733,10 +1796,6 @@ async def test_on_reaction_clear() -> None: assert latest is not None assert latest.embeds[0].description == emb.description - assert await Bot.on_reaction_clear( - MockMessage(guild=MockGuild()), [reaction, other_reaction], - ) is None - @MarkAsync async def test_on_guild_channel_delete() -> None: @@ -1752,10 +1811,6 @@ async def test_on_guild_channel_delete() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_guild_channel_delete( - MockChannel(guild=MockGuild()), - ) is None - @MarkAsync async def test_on_guild_channel_create() -> None: @@ -1771,10 +1826,6 @@ async def test_on_guild_channel_create() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_guild_channel_create( - MockChannel(guild=MockGuild()), - ) is None - @MarkAsync async def test_on_member_ban() -> None: @@ -1790,10 +1841,6 @@ async def test_on_member_ban() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_member_ban( - MockGuild(), MockMember(guild=MockGuild()), - ) is None - @MarkAsync async def test_on_member_unban() -> None: @@ -1811,10 +1858,6 @@ async def test_on_member_unban() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_member_unban( - MockGuild(), MockMember(guild=MockGuild()), - ) is None - @MarkAsync async def test_on_member_join() -> None: @@ -1833,8 +1876,6 @@ async def test_on_member_join() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_member_join(MockMember(guild=MockGuild())) is None - @MarkAsync async def test_on_member_remove() -> None: @@ -1857,8 +1898,6 @@ async def test_on_member_remove() -> None: assert latest is not None assert latest.embeds[0].description == log.description - assert await Bot.on_member_remove(MockMember(guild=MockGuild())) is None - @MarkAsync async def test_on_member_update() -> None: @@ -1878,69 +1917,81 @@ async def test_on_member_update() -> None: assert latest.embeds[0].description == log.description r = MockRole() - r.guild = guild + guild.assign_guild_to_role(r) new = MockMember(nick="a", guild=guild, roles=[r]) assert new.guild is not None emb = await Bot.on_member_update(old, new) assert emb is not None log = logs.log_member_roles_change(old, new) assert emb.description == log.description - assert log.description == ( - "Role <@&123456789> added to <@123456789>." - ) + assert log.description == "Role <@&123456789> added to <@123456789>." emb = await Bot.on_member_update(new, old) assert emb is not None log = logs.log_member_roles_change(new, old) assert emb.description == log.description - assert log.description == ( - "Role <@&123456789> removed from <@123456789>." - ) + assert log.description == "Role <@&123456789> removed from <@123456789>." - m = MockMember(guild=MockGuild()) - assert await Bot.on_member_update(m, m) is None + assert (await Bot.on_member_update(old, old)) is None @MarkAsync async def test_on_message_edit() -> None: ch = MockChannel(name=misc.LogChannelName) - member = MockMember() + member = MockMember(user=MockUser(user_id=37)) g = MockGuild(channels=[ch, MockChannel(name="infractions")], roles=[]) assert len(g.roles) == 1 before = MockMessage(content="old", author=member, guild=g) + assert (await Bot.on_message_edit(before, before)) is None after = MockMessage(content="new", author=member, guild=g) emb = await Bot.on_message_edit(before, after) assert isinstance(emb, nextcord.Embed) log = logs.log_edit_msg(before, after) assert emb.description == log.description assert emb.description == ( - "Messaged edited by <@123456789> in <#123456789>." + f"Messaged edited by {member.mention} in <#123456789>." ) assert emb.fields[0].value == before.content assert emb.fields[1].value == ( f"new\n[Jump to Message]({after.jump_url})" ) - after.content = "http://dizcort.com free nitro!" + scam_message = "http://dizcort.com free nitro!" + after.content = scam_message emb = await Bot.on_message_edit(before, after) assert emb is not None - - assert len(g.roles) == 2 - assert g.roles[1].name == "Muted" + assert len(g.roles) == len(member.roles) == 2 + assert g.roles[1].name == member.roles[1].name == "Muted" # TODO: edit after to have content of len > 1024 via message.edit h = [i async for i in ch.history()] - assert not any(i.content == after.content for i in ch.messages) + assert len(h) == 3 + assert not any(i.content == scam_message for i in ch.messages) assert h[0].embeds[0].description == log.description assert h[1].content.startswith("Deleted possible") + assert h[2].embeds[0].description == emb.description + + # Mute should not add muted role twice, should still log edit twice + assert len(g.roles) == len(member.roles) == 2 + assert member.roles[1].name == "Muted" + emb = await Bot.on_message_edit( + before, MockMessage(content=scam_message, author=member, guild=g), + ) + assert emb is not None + assert len(member.roles) == 2 + h = [i async for i in ch.history()] + assert len(h) == 5 + assert h[3].content == h[1].content + assert h[4].embeds[0].description == h[2].embeds[0].description - assert await Bot.on_message_edit(MockMessage(), MockMessage()) is None + after.guild = None + after.content = "bar" + assert (await Bot.on_message_edit(before, after)) is None @MarkAsync async def test_on_thread_join() -> None: ch = MockChannel(channel_id=0, name=misc.LogChannelName) - guild = MockGuild(channels=[ch]) - ch.guild = guild + ch.guild = MockGuild(channels=[ch]) thread = MockThread(parent=ch, me=MockMember(), name="Foo") assert await Bot.on_thread_join(thread) is None @@ -1956,17 +2007,11 @@ async def test_on_thread_join() -> None: assert latest is not None assert latest.embeds[0].description == emb.description - ch.name = "bar" - assert await Bot.on_thread_join( - MockThread(parent=ch, me=None, name="Foo"), - ) is None - @MarkAsync async def test_on_thread_delete() -> None: ch = MockChannel(channel_id=0, name=misc.LogChannelName) - guild = MockGuild(channels=[ch]) - ch.guild = guild + ch.guild = MockGuild(channels=[ch]) thread = MockThread(parent=ch, name="Foo") emb = await Bot.on_thread_delete(thread) assert emb is not None @@ -1977,17 +2022,11 @@ async def test_on_thread_delete() -> None: assert latest is not None assert latest.embeds[0].description == emb.description - ch.name = "bar" - assert await Bot.on_thread_delete( - MockThread(parent=ch, me=MockMember(), name="Foo"), - ) is None - @MarkAsync async def test_on_thread_update() -> None: ch = MockChannel(channel_id=0, name=misc.LogChannelName) - guild = MockGuild(channels=[ch]) - ch.guild = guild + ch.guild = MockGuild(channels=[ch]) before = MockThread(parent=ch, name="Foo") after = MockThread(parent=ch, name="Foo") assert await Bot.on_thread_update(before, after) is None @@ -2024,19 +2063,13 @@ async def test_on_thread_update() -> None: assert latest is not None assert latest.embeds[0].description == emb.description - ch.name = "bar" - th = MockThread(parent=ch, name="Foo") - assert await Bot.on_thread_update(th, th) is None - @MarkAsync async def test_cmd_dice() -> None: - ch = MockChannel() ctx = MockContext( Bot.BeardlessBot, - channel=ch, author=MockMember(MockUser(user_id=400005678)), - guild=MockGuild(channels=[ch]), + guild=MockGuild(), ) emb: nextcord.Embed = await Bot.cmd_dice(ctx) assert isinstance(emb, nextcord.Embed) @@ -2044,70 +2077,57 @@ async def test_cmd_dice() -> None: assert emb.description.startswith( "Welcome to Beardless Bot dice, <@400005678>!", ) - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None assert latest.embeds[0].description == emb.description @MarkAsync async def test_cmd_bucks() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) + ctx = MockContext(Bot.BeardlessBot) emb: nextcord.Embed = await Bot.cmd_bucks(ctx) assert isinstance(emb, nextcord.Embed) assert isinstance(emb.description, str) assert emb.description.startswith( "BeardlessBucks are this bot's special currency.", ) - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None assert latest.embeds[0].description == emb.description @MarkAsync async def test_cmd_hello() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) + ctx = MockContext(Bot.BeardlessBot) with pytest.MonkeyPatch.context() as mp: mp.setattr("random.choice", operator.itemgetter(0)) assert await Bot.cmd_hello(ctx) == 1 - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None assert latest.content == "How ya doin'?" with pytest.MonkeyPatch.context() as mp: mp.setattr("random.choice", operator.itemgetter(5)) assert await Bot.cmd_hello(ctx) == 1 - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None assert latest.content == "Hi!" @MarkAsync async def test_cmd_source() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) + ctx = MockContext(Bot.BeardlessBot) assert await Bot.cmd_source(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None - emb = m.embeds[0] - assert emb.title == "Beardless Bot Fun Facts" + assert m.embeds[0].title == "Beardless Bot Fun Facts" @MarkAsync async def test_cmd_add() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) + ctx = MockContext(Bot.BeardlessBot) assert await Bot.cmd_add(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] misc_emb = misc.Invite_Embed @@ -2118,11 +2138,8 @@ async def test_cmd_add() -> None: @MarkAsync -async def test_fact() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) +async def test_cmd_fact() -> None: + ctx = MockContext(Bot.BeardlessBot) first_fact = ( "The scientific term for brain freeze" " is sphenopalatine ganglioneuralgia." @@ -2133,7 +2150,7 @@ async def test_fact() -> None: mp.setattr("random.randint", lambda _, y: y) assert await Bot.cmd_fact(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert emb.description == first_fact @@ -2142,12 +2159,9 @@ async def test_fact() -> None: @MarkAsync async def test_cmd_animals() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) + ctx = MockContext(Bot.BeardlessBot) assert await Bot.cmd_animals(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert len(emb.fields) == 9 @@ -2160,7 +2174,7 @@ async def test_cmd_animals() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("misc.AnimalList", ("frog", "cat")) assert await Bot.cmd_animals(ctx) == 1 - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None assert len(latest.embeds[0].fields) == 3 @@ -2185,16 +2199,13 @@ def test_tweet() -> None: @MarkAsync async def test_cmd_tweet() -> None: - ch = MockChannel() ctx = MockContext( - Bot.BeardlessBot, - channel=ch, - guild=MockGuild(guild_id=Bot.EggGuildId, channels=[ch]), + Bot.BeardlessBot, guild=MockGuild(guild_id=Bot.EggGuildId), ) with pytest.MonkeyPatch.context() as mp: mp.setattr("misc.tweet", lambda: "foobar!") assert await Bot.cmd_tweet(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert emb.description == "\nfoobar" @@ -2350,16 +2361,23 @@ def test_member_search_invalid() -> None: assert misc.member_search(text, text.content) is None -def test_register() -> None: +@MarkAsync +async def test_cmd_register() -> None: bb = MockMember( MockUser("Beardless Bot", discriminator="5757", user_id=misc.BbId), "Beardless Bot", ) bucks.reset(bb) - assert bucks.register(bb).description == ( + ctx = MockContext(Bot.BeardlessBot, author=bb) + assert (await Bot.cmd_register(ctx)) == 1 + emb = bucks.register(bb) + assert emb.description == ( "You are already in the system! Hooray! You" f" have 200 BeardlessBucks, <@{misc.BbId}>." ) + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description == emb.description bb._user.name = ",badname," assert bucks.register(bb).description == ( @@ -2367,6 +2385,7 @@ def test_register() -> None: ) +@MarkAsync @pytest.mark.parametrize( ("target", "result"), [ @@ -2375,14 +2394,25 @@ def test_register() -> None: "'s balance is 200", ), (MockMember(MockUser(",")), bucks.CommaWarn.format("<@123456789>")), - ("Invalid user", "Invalid user!"), ], ) -def test_balance(target: nextcord.User, result: str) -> None: +async def test_cmd_balance(target: nextcord.User, result: str) -> None: msg = MockMessage("!bal", guild=MockGuild()) desc = bucks.balance(target, msg).description + ctx = MockContext(Bot.BeardlessBot, author=target, message=msg) assert isinstance(desc, str) assert result in desc + assert (await Bot.cmd_balance(ctx, target="")) == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description == desc + + +def test_balance_invalid() -> None: + msg = MockMessage("!bal", guild=MockGuild()) + desc = bucks.balance("Invalid user", msg).description + assert isinstance(desc, str) + assert "Invalid user!" in desc def test_reset() -> None: @@ -2505,8 +2535,7 @@ async def test_define_invalid_word_returns_no_results_found( url="https://api.dictionaryapi.dev/api/v2/entries/en_US/foo", status_code=misc.BadRequest, ) - emb = await misc.define("foo") - assert emb.description == "No results found." + assert (await misc.define("foo")).description == "No results found." @MarkAsync @@ -2525,30 +2554,26 @@ async def test_define_api_down_returns_error_message( @MarkAsync @pytest.mark.httpx_mock(can_send_already_matched_responses=True) async def test_cmd_define(httpx_mock: HTTPXMock) -> None: - ch = MockChannel(guild=MockGuild()) - ctx = MockContext(Bot.BeardlessBot, channel=ch) + ctx = MockContext(Bot.BeardlessBot) resp = [{"word": "f", "phonetics": [], "meanings": [{"definitions": []}]}] httpx_mock.add_response( url="https://api.dictionaryapi.dev/api/v2/entries/en_US/f", json=resp, ) assert await Bot.cmd_define(ctx, words="f") == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None - emb = m.embeds[0] definition = await misc.define("f") - assert emb.title == definition.title == "F" + assert m.embeds[0].title == definition.title == "F" @MarkAsync async def test_cmd_ping() -> None: Bot.BeardlessBot = MockBot(Bot.BeardlessBot) - ch = MockChannel(guild=MockGuild()) - ctx = MockContext(Bot.BeardlessBot, channel=ch) + ctx = MockContext(Bot.BeardlessBot) assert await Bot.cmd_ping(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None - emb = m.embeds[0] - assert emb.description == "Beardless Bot's latency is 25 ms." + assert m.embeds[0].description == "Beardless Bot's latency is 25 ms." Bot.BeardlessBot._connection.user = None assert await Bot.cmd_ping(ctx) == -1 @@ -2619,13 +2644,12 @@ async def test_cmd_flip() -> None: MockUser("Beardless Bot", discriminator="5757", user_id=misc.BbId), "Beardless Bot", ) - ch = MockChannel(guild=MockGuild()) ctx = MockContext( - Bot.BeardlessBot, MockMessage("!flip 0"), ch, bb, MockGuild(), + Bot.BeardlessBot, MockMessage("!flip 0"), author=bb, guild=MockGuild(), ) Bot.BlackjackGames = [] assert await Bot.cmd_flip(ctx, bet="0") == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert emb.description is not None @@ -2633,7 +2657,7 @@ async def test_cmd_flip() -> None: Bot.BlackjackGames.append(bucks.BlackjackGame(bb, 10)) assert await Bot.cmd_flip(ctx, bet="0") == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].description == bucks.FinMsg.format(f"<@{misc.BbId}>") @@ -2684,25 +2708,31 @@ async def test_cmd_blackjack() -> None: MockUser("Beardless Bot", discriminator="5757", user_id=misc.BbId), "Beardless Bot", ) - ch = MockChannel(guild=MockGuild()) - ctx = MockContext( - Bot.BeardlessBot, MockMessage("!blackjack 0"), ch, bb, MockGuild(), - ) + ctx = MockContext(Bot.BeardlessBot, author=bb, guild=MockGuild()) Bot.BlackjackGames = [] assert await Bot.cmd_blackjack(ctx, bet="all") == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None - emb = m.embeds[0] - assert emb.description is not None - assert emb.description.startswith("Your starting hand consists of") + assert m.embeds[0].description is not None + assert m.embeds[0].description.startswith("Your starting hand consists of") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) + Bot.BlackjackGames = [] + assert await Bot.cmd_blackjack(ctx, bet="all") == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description is not None + assert m.embeds[0].description.endswith( + f"You hit {bucks.BlackjackGame.Goal}! You win, {bb.mention}!", + ) Bot.BlackjackGames.append(bucks.BlackjackGame(bb, 10)) assert await Bot.cmd_blackjack(ctx, bet="0") == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None - emb = m.embeds[0] - assert emb.description is not None - assert emb.description == bucks.FinMsg.format(f"<@{misc.BbId}>") + assert m.embeds[0].description is not None + assert m.embeds[0].description == bucks.FinMsg.format(f"<@{misc.BbId}>") @MarkAsync @@ -2711,18 +2741,15 @@ async def test_cmd_deal() -> None: bb = MockMember( MockUser("Beardless,Bot", discriminator="5757", user_id=misc.BbId), ) - ch = MockChannel(guild=MockGuild()) - ctx = MockContext( - Bot.BeardlessBot, MockMessage("!hit"), ch, bb, MockGuild(), - ) + ctx = MockContext(Bot.BeardlessBot, author=bb, guild=MockGuild()) assert await Bot.cmd_deal(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].description == bucks.CommaWarn.format(f"<@{misc.BbId}>") bb._user.name = "Beardless Bot" assert await Bot.cmd_deal(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].description == bucks.NoGameMsg.format(f"<@{misc.BbId}>") @@ -2731,7 +2758,7 @@ async def test_cmd_deal() -> None: Bot.BlackjackGames = [] Bot.BlackjackGames.append(game) assert await Bot.cmd_deal(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert len(game.hand) == 3 @@ -2743,7 +2770,7 @@ async def test_cmd_deal() -> None: Bot.BlackjackGames = [] Bot.BlackjackGames.append(game) assert await Bot.cmd_deal(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert emb.description is not None @@ -2757,7 +2784,7 @@ async def test_cmd_deal() -> None: mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) mp.setattr("bucks.BlackjackGame.check_bust", lambda _: False) assert await Bot.cmd_deal(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert emb.description is not None @@ -2767,15 +2794,15 @@ async def test_cmd_deal() -> None: def test_blackjack_deal_to_player_treats_ace_as_1_when_going_over() -> None: game = bucks.BlackjackGame(MockMember(), 10) - game.hand = [11, 9] + game.hand = [8, 11] with pytest.MonkeyPatch.context() as mp: - game.deck = [2, 3, 4] + game.deck = [3, 4, 5] mp.setattr("random.randint", lambda x, _: x) game.deal_to_player() assert len(game.hand) == 3 assert sum(game.hand) == 12 assert game.message.startswith( - "You were dealt a 2, bringing your total to 22. To avoid busting," + "You were dealt a 3, bringing your total to 22. To avoid busting," " your Ace will be treated as a 1. Your new total is 12.", ) @@ -2785,6 +2812,7 @@ def test_blackjack_deal_to_player_wins_when_reaching_21() -> None: game = bucks.BlackjackGame(m, 10) game.hand = [10, 9] with pytest.MonkeyPatch.context() as mp: + game.deck = [2, 3, 4] mp.setattr("random.randint", lambda x, _: x) game.deal_to_player() assert game.message.startswith( @@ -2835,12 +2863,9 @@ async def test_cmd_stay() -> None: bb = MockMember( MockUser("Beardless,Bot", discriminator="5757", user_id=misc.BbId), ) - ch = MockChannel(guild=MockGuild()) - ctx = MockContext( - Bot.BeardlessBot, MockMessage("!stay"), ch, bb, MockGuild(), - ) + ctx = MockContext(Bot.BeardlessBot, author=bb, guild=MockGuild()) assert await Bot.cmd_stay(ctx) == 1 - m = await latest_message(ch) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].description == bucks.CommaWarn.format(f"<@{misc.BbId}>") # TODO: other branches @@ -2849,15 +2874,23 @@ async def test_cmd_stay() -> None: def test_blackjack_stay() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("random.randint", lambda x, _: x) - game = bucks.BlackjackGame(MockMember(), 0) + member = MockMember() + game = bucks.BlackjackGame(member, 0) game.hand = [10, 10, 1] game.dealerSum = 25 assert game.stay() == 1 + assert game.message.endswith( + f"to your balance, {member.mention}." + " Unfortunately, you bet nothing, so this was all pointless.", + ) + game.bet = 10 game.dealerSum = 20 assert game.stay() == 1 + assert game.message.endswith(f"to your balance, {member.mention}.") game.deal_to_player() assert game.stay() == 1 + assert game.message.endswith(f"from your balance, {member.mention}.") game.hand = [10, 10] assert game.stay() == 0 @@ -2915,10 +2948,15 @@ def test_info() -> None: m.roles = [guild.roles[0], guild.roles[0]] text = MockMessage("!info searchterm", guild=guild) m_info = misc.info("searchterm", text) + assert len(m_info.fields) == 3 assert m_info.fields[0].value == misc.truncate_time(m) + " UTC" assert m_info.fields[1].value == misc.truncate_time(m) + " UTC" assert m_info.fields[2].value == "<@&123456789>" + m.roles = [guild.roles[0]] + m_info = misc.info("searchterm", text) + assert len(m_info.fields) == 2 + assert misc.info("!infoerror", text).title == "Invalid target!" @@ -2927,16 +2965,15 @@ async def test_cmd_av() -> None: m = MockMember(MockUser("searchterm")) guild = MockGuild(members=[MockMember(), m]) text = MockMessage("!av searchterm", guild=guild) - ch = text.channel - assert isinstance(ch, nextcord.TextChannel) + assert isinstance(text.channel, nextcord.TextChannel) ctx = MockContext( - Bot.BeardlessBot, text, ch, m, MockGuild(), + Bot.BeardlessBot, text, text.channel, m, MockGuild(), ) avatar = str(misc.fetch_avatar(m)) emb = misc.avatar("searchterm", text) assert emb.image.url == avatar assert await Bot.cmd_av(ctx, target="searchterm") == 1 - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None assert emb.image.url == latest.embeds[0].image.url @@ -2957,10 +2994,9 @@ async def test_cmd_av() -> None: async def test_bb_help_command(caplog: pytest.LogCaptureFixture) -> None: help_command = misc.BbHelpCommand() assert help_command.command_attrs["aliases"] == ["commands"] - ch = MockChannel() author = MockMember() help_command.context = MockContext( - Bot.BeardlessBot, author=author, guild=None, channel=ch, + Bot.BeardlessBot, author=author, guild=None, ) await help_command.send_bot_help({}) @@ -2971,7 +3007,7 @@ async def test_bb_help_command(caplog: pytest.LogCaptureFixture) -> None: author.guild_permissions = nextcord.Permissions(manage_messages=False) await help_command.send_bot_help({}) - h = [i async for i in ch.history()] + h = [i async for i in help_command.context.history()] assert len(h[2].embeds[0].fields) == 17 assert len(h[1].embeds[0].fields) == 20 assert len(h[0].embeds[0].fields) == 15 @@ -3011,17 +3047,15 @@ def test_scam_check() -> None: @MarkAsync +@pytest.mark.flaky(reruns=2, reruns_delay=2) @pytest.mark.parametrize( "searchterm", ["лексика", "spaced words", "/", "'", "'foo'", "\\\"", " "], ) -async def test_search_valid(searchterm: str) -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) +async def test_cmd_search_valid(searchterm: str) -> None: + ctx = MockContext(Bot.BeardlessBot) assert await Bot.cmd_search(ctx, searchterm=searchterm) == 1 - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None url = latest.embeds[0].description assert isinstance(url, str) @@ -3035,13 +3069,11 @@ async def test_search_valid(searchterm: str) -> None: @MarkAsync -async def test_search_empty_argument_redirects_to_home() -> None: - ch = MockChannel() - ctx = MockContext( - Bot.BeardlessBot, channel=ch, guild=MockGuild(channels=[ch]), - ) +@pytest.mark.flaky(reruns=2, reruns_delay=2) +async def test_cmd_search_empty_argument_redirects_to_home() -> None: + ctx = MockContext(Bot.BeardlessBot) assert await Bot.cmd_search(ctx, searchterm="") == 1 - latest = await latest_message(ch) + latest = await latest_message(ctx) assert latest is not None url = latest.embeds[0].description assert isinstance(url, str) @@ -3068,6 +3100,7 @@ async def test_moose_failed_connection_raises_animal_exception( @MarkAsync +@pytest.mark.flaky(reruns=2, reruns_delay=2) @pytest.mark.parametrize("animal_name", misc.AnimalList) async def test_get_animal_with_good_url(animal_name: str) -> None: url = await misc.get_animal(animal_name) @@ -3119,7 +3152,7 @@ async def test_invalid_animal_raises_value_error() -> None: @MarkAsync -async def test_dog_api_down_raises_animal_exception( +async def test_dog_api_down_random_dog_raises_animal_exception( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response( @@ -3133,6 +3166,21 @@ async def test_dog_api_down_raises_animal_exception( await misc.get_dog() +@MarkAsync +async def test_dog_api_down_get_breeds_raises_animal_exception( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + url="https://dog.ceo/api/breeds/list/all", + status_code=522, + ) + + with pytest.raises( + misc.AnimalException, match="Failed to call Dog Animal API", + ): + await misc.get_dog("breeds") + + @MarkAsync async def test_animal_api_down_raises_animal_exception( httpx_mock: HTTPXMock, @@ -3198,7 +3246,12 @@ async def test_handle_messages() -> None: m = MockMessage( content="http://dizcort.com free nitro!", channel=ch, - guild=MockGuild(members=[u], channels=[infractions]), + guild=MockGuild(members=[u], channels=[ + MockChannel(name="spam"), + MockChannel(name=misc.LogChannelName), + infractions, + MockChannel(name="foobar"), + ]), author=u, ) u._user.bot = False @@ -3209,9 +3262,7 @@ async def test_handle_messages() -> None: assert ch.messages[0] == m latest = await latest_message(ch) assert latest is not None - assert latest.content == ( - "http://dizcort.com free nitro!" - ) + assert latest.content == "http://dizcort.com free nitro!" assert (await latest_message(infractions)) is None assert len(list(infractions.messages)) == 0 assert len(list(ch.messages)) == 1 @@ -3238,48 +3289,72 @@ async def test_handle_messages() -> None: assert msg.content.startswith( "Deleted possible scam nitro link sent by <@999999999>", ) - dm = await latest_message(await m.author._get_channel()) + dm = await latest_message(m.author) assert dm is not None assert dm.content.startswith("This is an automated message.") + assert dm == await latest_message(await m.author._get_channel()) + assert dm == await latest_message(u) @MarkAsync async def test_cmd_guide() -> None: - ctx = MockContext(Bot.BeardlessBot, author=MockMember(), guild=MockGuild()) + ctx = MockContext(Bot.BeardlessBot, guild=MockGuild()) ctx.message.type = nextcord.MessageType.default assert await Bot.cmd_guide(ctx) == 0 assert ctx.guild is not None ctx.guild.id = Bot.EggGuildId assert await Bot.cmd_guide(ctx) == 1 - m = await latest_message(ctx.channel) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].title == "The Eggsoup Improvement Guide" @MarkAsync async def test_cmd_reddit() -> None: - ctx = MockContext(Bot.BeardlessBot, author=MockMember(), guild=MockGuild()) + ctx = MockContext(Bot.BeardlessBot, guild=MockGuild()) ctx.message.type = nextcord.MessageType.default assert await Bot.cmd_reddit(ctx) == 0 assert ctx.guild is not None ctx.guild.id = Bot.EggGuildId assert await Bot.cmd_reddit(ctx) == 1 - m = await latest_message(ctx.channel) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].title == "The Official Eggsoup Subreddit" +@MarkAsync +async def test_cmd_buy() -> None: + ctx = MockContext(Bot.BeardlessBot, author=MockMember(), guild=MockGuild()) + assert await Bot.cmd_buy(ctx, "invalid") == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].title == "Beardless Bot Special Colors" + assert m.embeds[0].description == ( + "Invalid color. Choose blue, red, orange," + f" or pink, {ctx.author.mention}." + ) + + assert await Bot.cmd_buy(ctx, "blue") == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].title == "Beardless Bot Special Colors" + assert m.embeds[0].description == ( + f"That color role does not exist in this server, {ctx.author.mention}." + ) + # TODO: test remaining branches + # https://github.com/LevBernstein/BeardlessBot/issues/47 + + @MarkAsync async def test_cmd_mute() -> None: Bot.BeardlessBot = MockBot(Bot.BeardlessBot) - g = MockGuild() ctx = MockContext( Bot.BeardlessBot, message=MockMessage(content="!mute foo"), author=MockMember(admin_powers=True), - guild=g, + guild=MockGuild(), ) # if the MemberConverter fails @@ -3302,15 +3377,54 @@ async def test_cmd_mute() -> None: # https://github.com/LevBernstein/BeardlessBot/issues/47 +@MarkAsync +async def test_cmd_unmute() -> None: + Bot.BeardlessBot = MockBot(Bot.BeardlessBot) + ctx = MockContext( + Bot.BeardlessBot, + message=MockMessage(content="!unmute foo"), + author=MockMember(admin_powers=False), + guild=MockGuild(), + ) + assert await Bot.cmd_unmute(ctx, "foo") == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].title == "Beardless Bot Unmute" + assert m.embeds[0].description == misc.Naughty.format(ctx.author.mention) + + ctx.author = MockMember(admin_powers=True) + assert await Bot.cmd_unmute(ctx, "foo") == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description == ( + "Error! Muted role does not exist! Can't unmute!" + ) + + assert ctx.guild is not None + assert await misc.create_muted_role(ctx.guild) is not None + assert await Bot.cmd_unmute(ctx, None) == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description == f"Invalid target, {ctx.author.mention}." + + assert await Bot.cmd_unmute(ctx, "foo") == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description == ( + "Invalid target! Target must be a mention or user ID." + ) + # TODO: test remaining branches + # https://github.com/LevBernstein/BeardlessBot/issues/47 + + @MarkAsync async def test_process_mute_target_converts_target() -> None: Bot.BeardlessBot = MockBot(Bot.BeardlessBot) - g = MockGuild() ctx = MockContext( Bot.BeardlessBot, message=MockMessage(content="!mute foo"), author=MockMember(MockUser("foo"), admin_powers=True), - guild=g, + guild=MockGuild(), ) assert await misc.process_mute_target( ctx, "foo", Bot.BeardlessBot, @@ -3358,6 +3472,8 @@ def test_process_mute_duration() -> None: None, "4z invalid time", None, ) + assert misc.process_mute_duration(None, "") == (None, "", None) + def test_get_target() -> None: author = MockMember(MockUser("author")) @@ -3373,7 +3489,7 @@ def test_get_target() -> None: @MarkAsync async def test_thread_creation_does_not_invoke_commands() -> None: - ctx = MockContext(Bot.BeardlessBot, author=MockMember(), guild=MockGuild()) + ctx = MockContext(Bot.BeardlessBot, guild=MockGuild()) ctx.message.type = nextcord.MessageType.thread_created for command in Bot.BeardlessBot.commands: if command.name != "help": @@ -3422,12 +3538,12 @@ def mock_raise_discord_exception(bot: commands.Bot, token: str) -> None: @MarkAsync async def test_cmd_pins() -> None: - ctx = MockContext(Bot.BeardlessBot, author=MockMember(), guild=None) + ctx = MockContext(Bot.BeardlessBot, guild=None) assert await Bot.cmd_pins(ctx) == -1 ctx.guild = MockGuild() assert (await Bot.cmd_pins(ctx)) == 0 - m = await latest_message(ctx.channel) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].title == ( f"Try using !spar in the {misc.SparChannelName} channel." @@ -3436,13 +3552,23 @@ async def test_cmd_pins() -> None: assert hasattr(ctx.channel, "name") ctx.channel.name = misc.SparChannelName assert (await Bot.cmd_pins(ctx)) == 1 - m = await latest_message(ctx.channel) + m = await latest_message(ctx) assert m is not None assert m.embeds[0].title == "How to use this channel." -def test_brawl_commands() -> None: - assert len(brawl.brawl_commands().fields) == 6 +@MarkAsync +async def test_cmd_brawl() -> None: + ctx = MockContext(Bot.BeardlessBot, guild=MockGuild()) + Bot.BrawlKey = "Foo" + assert (await Bot.cmd_brawl(ctx)) == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].title == "Beardless Bot Brawlhalla Commands" + assert len(m.embeds[0].fields) == 6 + + Bot.BrawlKey = None + assert (await Bot.cmd_brawl(ctx)) == 0 def test_fetch_brawl_id() -> None: @@ -3451,7 +3577,7 @@ def test_fetch_brawl_id() -> None: def test_get_top_dps() -> None: - payload: dict[str, int | str] = { + payload: dict[str, str | int] = { "matchtime": 3, "legend_name_key": "sidra", "damagedealt": "10", @@ -3464,7 +3590,7 @@ def test_get_top_dps() -> None: def test_get_top_ttk() -> None: - payload: dict[str, int | str] = { + payload: dict[str, str | int] = { "matchtime": 3, "legend_name_key": "sidra", "damagedealt": "10", @@ -3476,6 +3602,28 @@ def test_get_top_ttk() -> None: assert top_ttk[1] == 0.5 +def test_get_top_legend() -> None: + legends: list[dict[str, str | int]] = [ + {"legend_name_key": "sidra", "rating": 1397}, + {"legend_name_key": "jhala", "rating": 1399}, + {"legend_name_key": "xull", "rating": 1398}, + ] + top_legend = brawl.get_top_legend(legends) + assert top_legend is not None + assert len(top_legend) == 2 + assert top_legend[0] == "jhala" + assert top_legend[1] == 1399 + + +def test_get_twos_rank_no_teams_returns_unmodified_embed() -> None: + emb = misc.bb_embed("test embed", "test description") + assert len(emb.fields) == 0 + new_emb = brawl.get_twos_rank(emb, {"2v2": []}) + assert len(emb.fields) == 0 + assert emb.title == new_emb.title + assert emb.description == new_emb.description + + @MarkAsync async def test_claim_profile() -> None: assert brawl.fetch_brawl_id(Bot.OwnerId) == OwnerBrawlId @@ -3484,19 +3632,19 @@ async def test_claim_profile() -> None: ctx = MockContext( Bot.BeardlessBot, author=MockMember(MockUser(user_id=Bot.OwnerId)), ) - with pytest.MonkeyPatch.context() as mp: - mp.setattr("Bot.BrawlKey", lambda _: "Foo") - try: - assert (await Bot.cmd_brawlclaim(ctx, "1")) == 1 - async with aiofiles.open("resources/claimedProfs.json") as f: - assert profs_len == len(json.loads(await f.read())) - assert brawl.fetch_brawl_id(Bot.OwnerId) == 1 - m = await latest_message(ctx.channel) - assert m is not None - assert m.embeds[0].description == "Profile claimed." - finally: - brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) - assert brawl.fetch_brawl_id(Bot.OwnerId) == OwnerBrawlId + Bot.BrawlKey = "Foo" + try: + assert (await Bot.cmd_brawlclaim(ctx, "1")) == 1 + async with aiofiles.open("resources/claimedProfs.json") as f: + assert profs_len == len(json.loads(await f.read())) + assert brawl.fetch_brawl_id(Bot.OwnerId) == 1 + m = await latest_message(ctx) + assert m is not None + assert m.embeds[0].description == "Profile claimed." + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + assert brawl.fetch_brawl_id(Bot.OwnerId) == OwnerBrawlId + Bot.BrawlKey = None @MarkAsync @@ -3514,7 +3662,7 @@ async def test_brawl_api_call_raises_httpx_exception_with_bad_status_code( @MarkAsync -async def test_get_rank_monkeypatched_for_1s_top_rating( +async def test_get_rank_1s_top_rating( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response( @@ -3557,9 +3705,42 @@ async def test_get_rank_monkeypatched_for_1s_top_rating( @MarkAsync -async def test_get_rank_monkeypatched_for_2s_top_rating( +async def test_get_rank_1s_top_rating_no_top_legend( httpx_mock: HTTPXMock, ) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/ranked?api_key=foo", + json={ + "name": "Foo", + "region": "us-east-1", + "tier": "Gold 2", + "rating": 1540, + "peak_rating": 1577, + "wins": 2, + "games": 3, + "legends": [], + "2v2": [], + }, + ) + + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_rank( + MockMember(MockUser(user_id=Bot.OwnerId)), "foo", + ) + assert emb.fields[0].name == "Ranked 1s" + assert emb.fields[0].value == ( + "**Gold 2** (1540/1577 Peak)\n2 W / 1 L / 66.7% winrate" + ) + assert hasattr(emb.color, "value") + assert emb.color is not None + assert emb.color.value == brawl.RankColors["Gold"] + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + +@MarkAsync +async def test_get_rank_2s_top_rating(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( url="https://api.brawlhalla.com/player/1/ranked?api_key=foo", json={ @@ -3567,6 +3748,14 @@ async def test_get_rank_monkeypatched_for_2s_top_rating( "region": "us-east-1", "rating": 0, "2v2": [ + { + "teamname": "Foo+Spam", + "tier": "Gold 2", + "rating": 1467, + "peak_rating": 1467, + "wins": 1, + "games": 1, + }, { "teamname": "Foo+Bar", "tier": "Platinum 3", @@ -3575,6 +3764,14 @@ async def test_get_rank_monkeypatched_for_2s_top_rating( "wins": 1, "games": 2, }, + { + "teamname": "Foo+Splat", + "tier": "Gold 2", + "rating": 1465, + "peak_rating": 1465, + "wins": 1, + "games": 1, + }, ], }, ) @@ -3597,7 +3794,7 @@ async def test_get_rank_monkeypatched_for_2s_top_rating( @MarkAsync -async def test_get_rank_monkeypatched_unplayed( +async def test_get_rank_unplayed( httpx_mock: HTTPXMock, ) -> None: httpx_mock.add_response( @@ -3677,7 +3874,7 @@ async def test_get_clan(httpx_mock: HTTPXMock) -> None: "clan_id": 2, "clan_name": "FooBar", "clan_create_date": 18000, - "clan_xp": 1257, + "clan_lifetime_xp": 1257, "clan": [{ "name": "Spam", "rank": "Leader", @@ -3706,6 +3903,201 @@ async def test_get_clan(httpx_mock: HTTPXMock) -> None: brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) +@MarkAsync +async def test_get_stats(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/stats?api_key=foo", + json={ + "clan": {"clan_id": 2, "clan_name": "test clan"}, + "brawlhalla_id": 1, + "name": "test player", + "games": 21, + "wins": 20, + "legends": [ + { + "legend_id": 26, + "legend_name_key": "jhala", + "damagedealt": "24000", + "damagetaken": "1200", + "kos": 60, + "falls": 40, + "matchtime": 1200, + "games": 20, + "wins": 20, + "xp": 100, + }, + { + "legend_id": 5, + "legend_name_key": "orion", + "damagedealt": "60", + "damagetaken": "120", + "kos": 2, + "falls": 3, + "matchtime": 36, + "games": 1, + "wins": 0, + "xp": 5, + }, + ], + }, + ) + member = MockMember(MockUser(user_id=0)) + stats = await brawl.get_stats(member, "foo") + assert stats.description == brawl.UnclaimedMsg.format("<@0>") + + member._user.id = Bot.OwnerId + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_stats(member, "foo") + assert emb.footer.text == "Brawl ID 1" + assert len(emb.fields) == 4 + assert emb.fields[0].value == "test player" + assert emb.fields[1].value == ( + "20 Wins / 1 Losses\n21 Games\n95.2% Winrate" + ) + assert emb.fields[2].value == ( + "**Most Played:** Jhala\n**Highest Winrate:** Jhala, 100.0%" + "\n**Highest Avg DPS:** Jhala, 20.0\n**Shortest Avg TTK:**" + " Orion, 18.0s" + ) + assert emb.fields[3].value == "test clan\nClan ID 2" + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + +@MarkAsync +async def test_get_stats_no_clan(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/stats?api_key=foo", + json={ + "brawlhalla_id": 1, + "name": "test player", + "games": 20, + "wins": 20, + "legends": [ + { + "legend_id": 26, + "legend_name_key": "jhala", + "damagedealt": "24000", + "damagetaken": "1200", + "kos": 60, + "falls": 40, + "matchtime": 1200, + "games": 20, + "wins": 20, + "xp": 100, + }, + ], + }, + ) + member = MockMember(MockUser(user_id=Bot.OwnerId)) + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_stats(member, "foo") + assert len(emb.fields) == 3 + assert emb.fields[0].value == "test player" + assert emb.fields[1].value == ( + "20 Wins / 0 Losses\n20 Games\n100.0% Winrate" + ) + assert emb.fields[2].value == ( + "**Most Played:** Jhala\n**Highest Winrate:** Jhala, 100.0%" + "\n**Highest Avg DPS:** Jhala, 20.0\n**Shortest Avg TTK:**" + " Jhala, 20.0s" + ) + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + +@MarkAsync +async def test_get_stats_no_advanced_stats(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/stats?api_key=foo", + json={ + "clan": {"clan_id": 2, "clan_name": "test clan"}, + "brawlhalla_id": 1, + "name": "test player", + "games": 0, + "wins": 0, + "legends": [{"legend_name_key": "jhala"}], + }, + ) + member = MockMember(MockUser(user_id=Bot.OwnerId)) + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_stats(member, "foo") + assert emb.footer.text == "Brawl ID 1" + assert len(emb.fields) == 3 + assert emb.fields[0].value == "test player" + assert emb.fields[1].value == ( + "0 Wins / 0 Losses\n0 Games\n0.0% Winrate" + ) + assert emb.fields[2].value == "test clan\nClan ID 2" + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + +@MarkAsync +async def test_get_stats_no_legends(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/stats?api_key=foo", + json={ + "clan": {"clan_id": 2, "clan_name": "test clan"}, + "brawlhalla_id": 1, + "name": "test player", + "games": 20, + "wins": 20, + }, + ) + member = MockMember(MockUser(user_id=Bot.OwnerId)) + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_stats(member, "foo") + assert len(emb.fields) == 3 + assert emb.fields[0].value == "test player" + assert emb.fields[1].value == ( + "20 Wins / 0 Losses\n20 Games\n100.0% Winrate" + ) + assert emb.fields[2].value == "test clan\nClan ID 2" + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + +@MarkAsync +async def test_get_stats_no_clan_or_legends(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/stats?api_key=foo", + json={ + "brawlhalla_id": 1, "name": "test player", "games": 20, "wins": 20, + }, + ) + member = MockMember(MockUser(user_id=Bot.OwnerId)) + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_stats(member, "foo") + assert len(emb.fields) == 2 + assert emb.fields[0].value == "test player" + assert emb.fields[1].value == ( + "20 Wins / 0 Losses\n20 Games\n100.0% Winrate" + ) + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + +@MarkAsync +async def test_get_stats_no_stats(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.brawlhalla.com/player/1/stats?api_key=foo", json={}, + ) + member = MockMember(MockUser(user_id=Bot.OwnerId)) + try: + brawl.claim_profile(Bot.OwnerId, 1) + emb = await brawl.get_stats(member, "foo") + assert emb.description is not None + assert emb.description.startswith("This profile doesn't have stats") + finally: + brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) + + @MarkAsync async def test_get_clan_not_in_a_clan(httpx_mock: HTTPXMock) -> None: httpx_mock.add_response( @@ -3772,7 +4164,7 @@ async def test_random_brawl_invalid() -> None: # Tests for commands that require a Brawlhalla API key: -if BrawlKey: +if BrawlKey: # pragma: no branch @MarkAsync async def test_random_brawl_with_brawl_key() -> None: assert BrawlKey is not None @@ -3787,7 +4179,6 @@ async def test_random_brawl_with_brawl_key() -> None: @MarkAsync async def test_pull_legends() -> None: - # This one should not be mocked; just remove the sleep assert BrawlKey is not None await asyncio.sleep(5) old_legends = brawl.fetch_legends() @@ -3822,27 +4213,3 @@ async def test_legend_info() -> None: ) assert await brawl.legend_info(BrawlKey, "invalidname") is None - - @MarkAsync - async def test_get_stats() -> None: - assert BrawlKey is not None - await asyncio.sleep(5) - member = MockMember(MockUser(user_id=0)) - stats = await brawl.get_stats(member, BrawlKey) - assert stats.description == brawl.UnclaimedMsg.format("<@0>") - - member._user.id = Bot.OwnerId - try: - brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) - emb = await brawl.get_stats(member, BrawlKey) - assert emb.footer.text == "Brawl ID " + str(OwnerBrawlId) - assert len(emb.fields) in {3, 4} - - brawl.claim_profile(Bot.OwnerId, 1247373426) - emb = await brawl.get_stats(member, BrawlKey) - assert emb.description is not None - assert emb.description.startswith( - "This profile doesn't have stats", - ) - finally: - brawl.claim_profile(Bot.OwnerId, OwnerBrawlId) diff --git a/brawl.py b/brawl.py index 7424a93..1ca0752 100644 --- a/brawl.py +++ b/brawl.py @@ -89,6 +89,8 @@ def get_brawl_data() -> dict[ def brawl_win_rate(j: dict[str, str | int]) -> float: assert isinstance(j["wins"], int) assert isinstance(j["games"], int) + if j["games"] == 0: + return 0.0 return round(j["wins"] / j["games"] * 100, 1) @@ -257,10 +259,9 @@ def get_top_legend( top_legend = None for legend in legends: assert isinstance(legend["rating"], int) - rating: int = legend["rating"] - if not top_legend or top_legend[1] < rating: + if not top_legend or top_legend[1] < legend["rating"]: assert isinstance(legend["legend_name_key"], str) - top_legend = legend["legend_name_key"], rating + top_legend = legend["legend_name_key"], legend["rating"] return top_legend @@ -274,7 +275,7 @@ def get_ones_rank(emb: Embed, r: dict[str, Any]) -> Embed: f"\nTop Legend: {top_legend[0].title()}, {top_legend[1]} Elo" ) emb.add_field(name="Ranked 1s", value=emb_val) - for thumb in RankedThumbnails: + for thumb in RankedThumbnails: # pragma: no branch if thumb[1] in r["tier"]: emb.colour = Colour(RankColors[thumb[1]]) emb.set_thumbnail(ThumbBase.format(*thumb)) @@ -304,7 +305,7 @@ def get_twos_rank(emb: Embed, r: dict[str, Any]) -> Embed: or peak_team["rating"] > r["rating"] ): # Higher 2s Elo than 1s Elo - for thumb in RankedThumbnails: + for thumb in RankedThumbnails: # pragma: no branch if thumb[1] in peak_team["tier"]: emb.colour = Colour(RankColors[thumb[1]]) emb.set_thumbnail(ThumbBase.format(*thumb)) @@ -340,13 +341,17 @@ async def get_rank(target: Member | User, brawl_key: str) -> Embed: return emb +def get_most_used(legend: dict[str, str | int]) -> tuple[str, int]: + assert isinstance(legend["legend_name_key"], str) + assert isinstance(legend["xp"], int) + return legend["legend_name_key"].title(), legend["xp"] + + def get_top_dps(legend: dict[str, str | int]) -> tuple[str, float]: assert isinstance(legend["matchtime"], int) assert isinstance(legend["legend_name_key"], str) - return ( - legend["legend_name_key"].title(), - round(int(legend["damagedealt"]) / legend["matchtime"], 1), - ) + dps = round(int(legend["damagedealt"]) / legend["matchtime"], 1) + return legend["legend_name_key"].title(), dps def get_top_ttk(legend: dict[str, str | int]) -> tuple[str, float]: @@ -362,32 +367,29 @@ def get_top_ttk(legend: dict[str, str | int]) -> tuple[str, float]: def get_top_legend_stats( legends: list[dict[str, str | int]], ) -> tuple[tuple[str, float | int] | None, ...]: - # TODO: unit test - # https://github.com/LevBernstein/BeardlessBot/issues/47 most_used: tuple[str, int] | None = None top_winrate: tuple[str, float] | None = None top_dps: tuple[str, float] | None = None lowest_ttk: tuple[str, float] | None = None for legend in legends: - assert isinstance(legend["xp"], int) assert isinstance(legend["legend_name_key"], str) - if not most_used or most_used[1] < legend["xp"]: - most_used = (legend["legend_name_key"].title(), legend["xp"]) - if legend["games"] and ( - top_winrate is None - or top_winrate[1] < brawl_win_rate(legend) + if legend.get("xp", 0) != 0 and ( + most_used is None or most_used[1] < get_most_used(legend)[1] + ): + most_used = get_most_used(legend) + if legend.get("games", 0) != 0 and ( + top_winrate is None or top_winrate[1] < brawl_win_rate(legend) ): top_winrate = ( legend["legend_name_key"].title(), brawl_win_rate(legend), ) - if legend["matchtime"] and ( - top_dps is None or top_dps[1] < get_top_dps(legend)[1] - ): - top_dps = get_top_dps(legend) - if legend["kos"] and ( - lowest_ttk is None or lowest_ttk[1] > get_top_ttk(legend)[1] - ): - lowest_ttk = get_top_ttk(legend) + if legend.get("matchtime", 0) != 0: + if top_dps is None or top_dps[1] < get_top_dps(legend)[1]: + top_dps = get_top_dps(legend) + if legend.get("kos", 0) != 0 and ( + lowest_ttk is None or lowest_ttk[1] > get_top_ttk(legend)[1] + ): + lowest_ttk = get_top_ttk(legend) return most_used, top_winrate, top_dps, lowest_ttk @@ -463,7 +465,7 @@ async def get_clan(target: Member | User, brawl_key: str) -> Embed: r["clan_name"], "**Clan Created:** {}\n**Experience:** {}\n**Members:** {}".format( str(datetime.fromtimestamp(r["clan_create_date"], TimeZone))[:-9], - r["clan_xp"], + r["clan_lifetime_xp"], len(r["clan"]), ), ).set_footer(text=f"Clan ID {r["clan_id"]}") diff --git a/resources/requirements.txt b/resources/requirements.txt index 5a2b0d0..2b243b9 100644 --- a/resources/requirements.txt +++ b/resources/requirements.txt @@ -1,24 +1,25 @@ aiofiles==25.1.0 -aiohttp==3.13.1 +aiohttp==3.13.2 audioop-lts==0.2.2; python_version>="3.13" -beautifulsoup4==4.14.2 +beautifulsoup4==4.14.3 codespell==2.4.1 -coverage==7.11.0 +coverage==7.12.0 docstr-coverage==2.3.2 flake8==7.3.0 flake8-comprehensions==3.17.0 -genbadge[all]==1.1.2 +genbadge[all]==1.1.3 httpx==0.28.1 -mypy[faster-cache, reports]==1.18.2 +mypy[faster-cache, reports]==1.19.0 mypy-extensions==1.1.0 nextcord==3.1.1 -pytest==8.4.2 -pytest-asyncio==1.2.0 +pytest==9.0.1 +pytest-asyncio==1.3.0 pytest-github-actions-annotate-failures==0.3.0 -pytest-httpx==0.35.0 -python-dotenv==1.1.1 +pytest-httpx==0.36.0 +pytest-rerunfailures==16.1 +python-dotenv==1.2.1 requests==2.32.5 -ruff==0.14.2 +ruff==0.14.8 steam==1.4.4 types-aiofiles==25.1.0.20251011 types-beautifulsoup4==4.12.0.20250516 From 6dd6ca949aa2586164898ce0806f2a5c763956b0 Mon Sep 17 00:00:00 2001 From: Lev Bernstein Date: Sat, 6 Dec 2025 09:25:50 -0500 Subject: [PATCH 2/2] Forgot the resources files, oops --- resources/images/tests.svg | 2 +- resources/legends.json | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/resources/images/tests.svg b/resources/images/tests.svg index 7ca85cd..450afde 100644 --- a/resources/images/tests.svg +++ b/resources/images/tests.svg @@ -1 +1 @@ -tests: 164tests164 \ No newline at end of file +tests: 178tests178 \ No newline at end of file diff --git a/resources/legends.json b/resources/legends.json index e1ee101..8ab477c 100644 --- a/resources/legends.json +++ b/resources/legends.json @@ -790,5 +790,17 @@ "dexterity": "4", "defense": "3", "speed": "8" + }, + { + "legend_id": 69, + "legend_name_key": "lady vera", + "bio_name": "Lady Vera", + "bio_aka": "The Luminary", + "weapon_one": "Chakram", + "weapon_two": "Scythe", + "strength": "3", + "dexterity": "7", + "defense": "8", + "speed": "4" } ] \ No newline at end of file