From 43f122946f77cb340ac1510e0b4215c1dcce52fd Mon Sep 17 00:00:00 2001 From: Kile Date: Thu, 21 May 2026 16:57:30 +0200 Subject: [PATCH 1/9] Big progress --- .coveragerc | 19 + .env.template | 3 +- .github/workflows/python-tests.yml | 29 + .gitignore | 7 + README.md | 4 +- alloy/config.alloy | 2 +- killua/Dockerfile | 2 +- killua/__init__.py | 4 +- killua/__main__.py | 7 +- killua/args.py | 11 + killua/bot.py | 1 + killua/cogs/__init__.py | 33 +- killua/cogs/actions.py | 2 +- killua/cogs/api.py | 78 +- killua/cogs/cards.py | 6 +- killua/cogs/dev.py | 6 +- killua/cogs/events.py | 30 +- killua/cogs/games.py | 4 +- killua/cogs/help.py | 4 +- killua/cogs/premium.py | 2 +- killua/cogs/shop.py | 6 +- killua/cogs/small_commands.py | 4 +- killua/cogs/tags.py | 16 +- killua/cogs/todo.py | 14 +- killua/static/cards.py | 2 +- killua/static/constants.py | 6 +- killua/tests/README.md | 219 ++++- killua/tests/__init__.py | 296 +++--- killua/tests/config.py | 4 + killua/tests/fixtures.py | 21 + killua/tests/groups/__init__.py | 40 +- killua/tests/groups/actions.py | 151 ++- killua/tests/groups/api.py | 911 +++++++++++++++++++ killua/tests/groups/bot_cov.py | 108 +++ killua/tests/groups/cards.py | 504 +++++++---- killua/tests/groups/cards_use_spells.py | 586 ++++++++++++ killua/tests/groups/deep_coverage.py | 83 ++ killua/tests/groups/dev.py | 153 +++- killua/tests/groups/economy.py | 387 ++++++++ killua/tests/groups/events.py | 295 ++++++ killua/tests/groups/games.py | 804 ++++++++++++++++ killua/tests/groups/help.py | 171 ++++ killua/tests/groups/image_manipulation.py | 266 ++++++ killua/tests/groups/moderation.py | 373 ++++++++ killua/tests/groups/premium.py | 222 +++++ killua/tests/groups/prometheus_cov.py | 104 +++ killua/tests/groups/shop.py | 449 +++++++++ killua/tests/groups/small_commands.py | 486 ++++++++++ killua/tests/groups/tags.py | 363 ++++++++ killua/tests/groups/todo.py | 722 +++++++++++++++ killua/tests/groups/unit_boost.py | 1008 +++++++++++++++++++++ killua/tests/groups/web_scraping.py | 194 ++++ killua/tests/harnesses/__init__.py | 97 ++ killua/tests/harnesses/assertions.py | 68 ++ killua/tests/harnesses/context.py | 16 + killua/tests/harnesses/dm_view.py | 51 ++ killua/tests/harnesses/interaction.py | 94 ++ killua/tests/harnesses/member_dm.py | 104 +++ killua/tests/harnesses/paginator.py | 37 + killua/tests/harnesses/poll_wyr.py | 154 ++++ killua/tests/harnesses/spell_use.py | 295 ++++++ killua/tests/harnesses/views.py | 47 + killua/tests/testing.py | 90 +- killua/tests/types/asset.py | 3 + killua/tests/types/bot.py | 52 +- killua/tests/types/channel.py | 17 +- killua/tests/types/context.py | 7 + killua/tests/types/guild.py | 32 +- killua/tests/types/interaction.py | 31 +- killua/tests/types/member.py | 13 + killua/tests/types/message.py | 31 +- killua/tests/types/role.py | 12 + killua/tests/types/testing_results.py | 16 +- killua/tests/types/user.py | 40 + killua/utils/classes/card.py | 4 +- killua/utils/test_db.py | 201 ++-- killua/utils/topgg.py | 109 +++ requirements-dev.txt | 2 + requirements.txt | 3 +- scripts/check_test_commands.py | 95 ++ 80 files changed, 10274 insertions(+), 669 deletions(-) create mode 100644 .coveragerc create mode 100644 .github/workflows/python-tests.yml create mode 100644 killua/tests/config.py create mode 100644 killua/tests/fixtures.py create mode 100644 killua/tests/groups/api.py create mode 100644 killua/tests/groups/bot_cov.py create mode 100644 killua/tests/groups/cards_use_spells.py create mode 100644 killua/tests/groups/deep_coverage.py create mode 100644 killua/tests/groups/economy.py create mode 100644 killua/tests/groups/events.py create mode 100644 killua/tests/groups/games.py create mode 100644 killua/tests/groups/help.py create mode 100644 killua/tests/groups/image_manipulation.py create mode 100644 killua/tests/groups/moderation.py create mode 100644 killua/tests/groups/premium.py create mode 100644 killua/tests/groups/prometheus_cov.py create mode 100644 killua/tests/groups/shop.py create mode 100644 killua/tests/groups/small_commands.py create mode 100644 killua/tests/groups/tags.py create mode 100644 killua/tests/groups/todo.py create mode 100644 killua/tests/groups/unit_boost.py create mode 100644 killua/tests/groups/web_scraping.py create mode 100644 killua/tests/harnesses/__init__.py create mode 100644 killua/tests/harnesses/assertions.py create mode 100644 killua/tests/harnesses/context.py create mode 100644 killua/tests/harnesses/dm_view.py create mode 100644 killua/tests/harnesses/interaction.py create mode 100644 killua/tests/harnesses/member_dm.py create mode 100644 killua/tests/harnesses/paginator.py create mode 100644 killua/tests/harnesses/poll_wyr.py create mode 100644 killua/tests/harnesses/spell_use.py create mode 100644 killua/tests/harnesses/views.py create mode 100644 killua/utils/topgg.py create mode 100644 requirements-dev.txt create mode 100644 scripts/check_test_commands.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..86820a7c0 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,19 @@ +[run] +source = killua +omit = + killua/tests/* + killua/download.py + killua/migrate.py + +[report] +fail_under = 70 +show_missing = True +skip_empty = True +precision = 1 +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + if __name__ == .__main__.: + +[html] +directory = htmlcov diff --git a/.env.template b/.env.template index e5e6c4498..030c75ab8 100644 --- a/.env.template +++ b/.env.template @@ -3,7 +3,8 @@ TOKEN="bot token" PXLAPI="plxapi token" PATREON="patreon token" DBL_TOKEN="discord bot list token" -TOPGG_TOKEN="top.gg token" +# v1 only: Top.gg project page → Integrations & API → create/copy token (not legacy v0 bot token) +TOPGG_TOKEN= API_KEY="key used in Killua API" MODE="dev" HASH_SECRET="hash secret" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 000000000..9e6f63731 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,29 @@ +name: Python tests and coverage + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Command vs test class inventory (report) + run: python scripts/check_test_commands.py + + - name: Tests and coverage gate + run: | + coverage run -m killua -t + coverage report diff --git a/.gitignore b/.gitignore index 47b7ca7cd..6a3ae83e3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,9 +30,16 @@ assets/cdn/* #python stuff **/__pycache__ +# coverage.py +htmlcov/ +.coverage +.coverage.* +coverage.xml + # Rust build **/target **/env +**/.venv **/.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 92d8d7814..57330e2cb 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ While running Killua using Docker is more convenient, running from source is mor > Not running Killua in Docker will make you unable to use Grafana or Prometheus. The code handles this on its own but if you want to use either of these you must run Killua using docker-compose. You also do not need to run the rust proxy as the IPC connection will be direct. ### Bot process -Note: Killua runs on Python 3.9. Some later versions introduce breaking changes and are not supported. +Note: Killua supports **Python 3.9–3.13**. Use a virtual environment matching your installed version (`python3 -m venv env`). First, set up a virtual environment. Do so with `python3 -m venv env; source env/bin/activate` (for linux and macOS). To leave the virtual environment after you are done, simply run `deactivate` @@ -257,7 +257,7 @@ To start the API, ideally you should use a different Terminal or screen/tmux ses
Running using Docker -Running from Docker, while taking longer to start up, is much more convenient and allows you to use Grafana and Prometheus. To run Killua using Docker, follow these steps: +Running from Docker, while taking longer to start up, is much more convenient and allows you to use Grafana and Prometheus. The bot image is built on **Python 3.13** (`killua/Dockerfile`). To run Killua using Docker, follow these steps: 1) Clone the repository (you need the `docker-compose.yml` file) diff --git a/alloy/config.alloy b/alloy/config.alloy index 75f7d5d58..e75de2643 100644 --- a/alloy/config.alloy +++ b/alloy/config.alloy @@ -41,7 +41,7 @@ discovery.relabel "docker_logs_filter" { rule { source_labels = ["__meta_docker_container_name"] - regex = "/(api|bot|proxy)" + regex = "/(rust_api|python_bot|zmq_proxy|api|bot|proxy)" action = "keep" } diff --git a/killua/Dockerfile b/killua/Dockerfile index 8fde63698..915ca803b 100644 --- a/killua/Dockerfile +++ b/killua/Dockerfile @@ -1,5 +1,5 @@ # Use an official Python runtime as a parent image -FROM python:3.9-slim AS base +FROM python:3.13-slim AS base ARG MYUID=1000 ARG MYGID=1000 diff --git a/killua/__init__.py b/killua/__init__.py index 1c4cbdbdf..3b163fa3e 100644 --- a/killua/__init__.py +++ b/killua/__init__.py @@ -33,7 +33,9 @@ async def main(): return await migrate() if args.test is not None: - return await run_tests(args.test) + return await run_tests( + args.test, json_output=getattr(args, "test_json", False) + ) if args.download: return await download(args.download.lower()) diff --git a/killua/__main__.py b/killua/__main__.py index 75deddf6b..514a26374 100644 --- a/killua/__main__.py +++ b/killua/__main__.py @@ -1,5 +1,8 @@ -from . import main +import sys from asyncio import run +from . import main + if __name__ == "__main__": - run(main()) + code = run(main()) + sys.exit(code if isinstance(code, int) else 0) diff --git a/killua/args.py b/killua/args.py index 4bd7da08a..fc568f850 100644 --- a/killua/args.py +++ b/killua/args.py @@ -6,6 +6,7 @@ class _Args: development: Optional[bool] = None migrate: Optional[bool] = None test: Optional[bool] = None + test_json: bool = False log: Optional[str] = None download: Optional[str] = None @@ -35,6 +36,12 @@ def get_args(cls) -> None: default=None, metavar=("cog", "command"), ) + parser.add_argument( + "--json", + dest="test_json", + action="store_true", + help="With --test, print only a JSON report to stdout (exit 1 if any test failed).", + ) parser.add_argument( "-l", "--log", @@ -68,9 +75,13 @@ def get_args(cls) -> None: parsed = parser.parse_args() + if getattr(parsed, "test_json", False) and parsed.test is None: + parser.error("--json requires -t/--test") + cls.development = parsed.development cls.migrate = parsed.migrate cls.test = parsed.test + cls.test_json = parsed.test_json cls.log = parsed.log cls.download = parsed.download cls.docker = parsed.docker diff --git a/killua/bot.py b/killua/bot.py index 0ad981438..aa986ff87 100644 --- a/killua/bot.py +++ b/killua/bot.py @@ -608,6 +608,7 @@ async def send_message( return ( await self._send_interaction_response(messageable, *args, **kwargs) if isinstance(messageable, discord.Interaction) + or getattr(messageable, "_killua_test_send_as_interaction", False) else await self._send_messageable_response(messageable, *args, **kwargs) ) diff --git a/killua/cogs/__init__.py b/killua/cogs/__init__.py index 938f2e440..6cf75ecea 100644 --- a/killua/cogs/__init__.py +++ b/killua/cogs/__init__.py @@ -1,35 +1,14 @@ -from importlib.abc import MetaPathFinder -import pkgutil -from importlib.util import find_spec +import importlib import pkgutil # This module's submodules. all_cogs = [] -for loader, name, pkg in pkgutil.walk_packages(__path__): - # Load the module. - loader = ( - loader.find_module(name, None) - if isinstance(loader, MetaPathFinder) - else loader.find_module(name) - ) - module = loader.load_module(name) +_package = __name__ - # Make it a global. +for _finder, name, _ispkg in pkgutil.iter_modules(__path__): + if name.startswith("_"): + continue + module = importlib.import_module(f"{_package}.{name}") globals()[name] = module - # Put it in the list. all_cogs.append(module) - # This module's submodules. - all_cogs = [] - for loader, name, is_pkg in pkgutil.iter_modules(__path__): - # Load the module. - spec = find_spec(name) - if not spec: - continue - if spec.loader is None: - continue - module = spec.loader.load_module(name) - # Make it a global. - globals()[name] = module - # Put it in the list. - all_cogs.append(module) diff --git a/killua/cogs/actions.py b/killua/cogs/actions.py index 3cfda6846..930e513b7 100644 --- a/killua/cogs/actions.py +++ b/killua/cogs/actions.py @@ -218,7 +218,7 @@ def generate_users(self, users: List[discord.User], title: str) -> str: ): # embed titles have a max length of 256 characters. # If the name list contains too many names, stuff breaks. # This prevents that and displays the other people as "and x more" - userlist = userlist + f" *and {len(user)-(p+1)} more*" + userlist = userlist + f" *and {len(users)-(p+1)} more*" break if users[-1] == user and len(users) != 1: userlist = userlist + f" and {user.display_name}" diff --git a/killua/cogs/api.py b/killua/cogs/api.py index 2f28eaae1..a18e4ef1a 100644 --- a/killua/cogs/api.py +++ b/killua/cogs/api.py @@ -9,9 +9,9 @@ from zmq import ROUTER, Poller, POLLIN from zmq.asyncio import Context from io import BytesIO -from asyncio import create_task from PIL import Image, ImageDraw, ImageChops from typing import Tuple, Optional +import logging from logging import error from copy import deepcopy @@ -20,6 +20,7 @@ from killua.static.enums import Booster from killua.utils.classes import User, Guild from killua.cogs.tags import Tag, Tags +from killua.utils.topgg import post_announcement from killua.static.constants import ( DB, LOOTBOXES, @@ -40,6 +41,9 @@ from typing import List, Dict, Optional, Union, cast +TOPGG_ANNOUNCEMENT_TITLE_MAX = 100 +TOPGG_ANNOUNCEMENT_CONTENT_MAX = 2000 + class NewsMessage: def __init__( @@ -91,7 +95,7 @@ async def from_id(cls, client: BaseBot, news_id: str) -> "NewsMessage": if not obj: raise ValueError("News item not found") - cls.from_data(client, dict(obj)) + return cls.from_data(client, dict(obj)) @classmethod def relevant_channel_id(cls, _type: str) -> int: @@ -124,7 +128,7 @@ def guild(self) -> Optional[discord.Guild]: @property def url(self) -> str: - return f"{'http://localhost:5173' if self.client.is_dev else'https://beta.killua.dev'}/news/{self.id}" + return f"{'http://localhost:5173' if self.client.is_dev else'https://killua.dev'}/news/{self.id}" async def _make_view( self, @@ -226,7 +230,7 @@ async def send(self) -> int: raise ValueError("Invalid channel") view, files = await self._make_view() - message = await channel.send(view=view, files=files) + message: discord.Message = await channel.send(view=view, files=files) return message.id @@ -249,10 +253,14 @@ class IPCRoutes(commands.Cog): def __init__(self, client: BaseBot): self.client = client - create_task(self.start()) self.command_cache = {} + self._zmq_task = None + + async def cog_load(self): # pragma: no cover + """Start the ZMQ listener once the bot event loop is running.""" + self._zmq_task = create_task(self.start()) - async def start(self): + async def start(self): # pragma: no cover """Starts the zmq server asynchronously and handles incoming requests""" context = Context() socket = context.socket(ROUTER) @@ -897,6 +905,7 @@ async def news_save(self, data: dict) -> dict: if data.get("published", False): # If published, send Discord message message_id = await self._send_discord_message(news_item) + await self._publish_topgg_announcement(news_item) await DB.news.update_one( {"_id": news_id}, {"$set": {"messageId": message_id}} ) @@ -955,6 +964,7 @@ async def news_edit(self, data: dict) -> dict: # Publishing for the first time - send Discord message message_id = await self._send_discord_message(news_item) news_item["messageId"] = message_id + await self._publish_topgg_announcement(news_item) elif old_published and new_published and news_item.get("messageId"): # Already published, edit existing message await self._edit_discord_message(news_item["messageId"], news_item) @@ -974,13 +984,61 @@ async def news_edit(self, data: dict) -> dict: async def _send_discord_message(self, data: dict) -> int: """Send a Discord message for a news item""" news_message = NewsMessage.from_data(self.client, data) - if news_message.timestamp < UPDATE_AFTER or self.client.is_dev: + if news_message.timestamp < UPDATE_AFTER: # or self.client.is_dev: # Don't send messages for old news items or in dev mode return None message_id = await news_message.send() return message_id - async def _edit_discord_message(self, message_id: str, data: dict) -> None: + async def _publish_topgg_announcement(self, data: dict) -> None: + """Publish an announcement to Top.gg when a news item is published.""" + news_message = NewsMessage.from_data(self.client, data) + if news_message.timestamp < UPDATE_AFTER or self.client.is_dev: + logging.info( + "Skipping Top.gg announcement for news %s", + data.get("_id"), + ) + return + + title = self._format_topgg_title(data["type"], data["title"]) + content = self._format_topgg_content(news_message, data["content"]) + ok = await post_announcement( + self.client.session, title=title, content=content + ) + if not ok: + logging.warning( + "Top.gg announcement was not posted for news %s (title=%r)", + data.get("_id"), + title, + ) + + @staticmethod + def _format_topgg_title( + news_type: str, title: str, *, max_len: int = TOPGG_ANNOUNCEMENT_TITLE_MAX + ) -> str: + full = f"{news_type.capitalize()}: {title}" + if len(full) <= max_len: + return full + return full[: max_len - 3] + "..." + + @staticmethod + def _format_topgg_content( + news_message: NewsMessage, + content: str, + *, + max_len: int = TOPGG_ANNOUNCEMENT_CONTENT_MAX, + ) -> str: + if len(content) <= max_len: + return content + suffix = f"Read the rest at {news_message.url}" + suffix_block = f"\n\n{suffix}" + max_body = max_len - len(suffix_block) + if max_body < 1: + suffix_block = suffix + max_body = max_len - len(suffix_block) + return content[:max_body].rstrip() + suffix_block + + async def _edit_discord_message(self, message_id: str, data: dict) -> None: # pragma: no cover """Edit an existing Discord message""" try: news_message = NewsMessage.from_data(self.client, data) @@ -1180,7 +1238,7 @@ async def guild_tag_edit(self, data: dict) -> dict: return {"success": True, "message": "Tag edited successfully"} - async def guild_command_usage(self, data: dict) -> Union[dict, list]: + async def guild_command_usage(self, data: dict) -> Union[dict, list]: # pragma: no cover """Returns command usage stats for the server""" if self.client.run_in_docker is False: return {"error": "Command usage stats are only available when running in Docker."} @@ -1206,7 +1264,7 @@ async def guild_command_usage(self, data: dict) -> Union[dict, list]: formatted.append( { "name": res["metric"]["command"], - "group": res["metric"]["group"], + "group": res["metric"].get("group", None), "command_id": int(res["metric"]["command_id"]), "values": [(str(timestamp), int(value)) for timestamp, value in res["values"]] } diff --git a/killua/cogs/cards.py b/killua/cogs/cards.py index a0b53eed9..0c62f9191 100644 --- a/killua/cogs/cards.py +++ b/killua/cogs/cards.py @@ -769,7 +769,7 @@ async def _member_converter( """Converts the data to a discord.Member if possible""" try: return await commands.MemberConverter().convert(ctx, data) - except (commands.BadArgument, TypeError): + except (commands.BadArgument, TypeError, AttributeError): return None async def _use_converter( @@ -780,6 +780,8 @@ async def _use_converter( return None elif isinstance(args, int): return args + elif isinstance(args, discord.Member): + return args elif m := await self._member_converter(ctx, args): return m elif args.isdigit(): @@ -821,7 +823,7 @@ async def _use_check( if isinstance(args, int) and int(args) < 1: raise CheckFailure("You can't use an integer less than 1") - if add_args and add_args < 1: + if add_args is not None and int(add_args) < 1: raise CheckFailure("You can't use an integer less than 1") return card diff --git a/killua/cogs/dev.py b/killua/cogs/dev.py index 65359cffb..069f8ff87 100644 --- a/killua/cogs/dev.py +++ b/killua/cogs/dev.py @@ -174,7 +174,7 @@ def _get_stats_embed( ) return embed, file - async def all_top(self, ctx: commands.Context, top: List[tuple]) -> None: + async def all_top(self, ctx: commands.Context, top: List[tuple]) -> None: # pragma: no cover """Shows a list of all top commands""" def make_embed(page, embed: discord.Embed, pages): @@ -203,7 +203,7 @@ def make_embed(page, embed: discord.Embed, pages): async def group_top( self, ctx: commands.Context, top: List[tuple], interaction: discord.Interaction - ) -> None: + ) -> None: # pragma: no cover """Displays a pie chart of the top used commands in a group""" # A list of all valid groups as strings possible_groups = [ @@ -263,7 +263,7 @@ def get_command_extras(self, cmd: str): c = self.client.get_command(cmd.split(" ")[-1]) return c.extras - async def initial_top(self, ctx: commands.Context) -> None: + async def initial_top(self, ctx: commands.Context) -> None: # pragma: no cover # Convert the ids to actually command names usage_data: Dict[str, int] = (await DB.const.find_one({"_id": "usage"}))[ "command_usage" diff --git a/killua/cogs/events.py b/killua/cogs/events.py index a7f23f153..b2348e9df 100644 --- a/killua/cogs/events.py +++ b/killua/cogs/events.py @@ -18,8 +18,8 @@ from killua.utils.classes import Guild, Book, User, Card from killua.static.enums import PrintColors from killua.migrate import migrate_requiring_bot +from killua.utils.topgg import post_metrics from killua.static.constants import ( - TOPGG_TOKEN, DBL_TOKEN, PatreonBanner, DB, @@ -45,13 +45,14 @@ def __init__(self, client: BaseBot): self.client = client self.skipped_first = False self.status_started = False + self._initial_stats_posted = False self.log_channel_id = 718818548524384310 @property def log_channel(self): return self.client.get_guild(GUILD).get_channel(self.log_channel_id) - async def _initialize_card_json(self, retry_timeout=5) -> None: + async def _initialize_card_json(self, retry_timeout=5) -> None: # pragma: no cover """Initializes the card json""" status = None while status != 200: @@ -89,17 +90,17 @@ async def _initialize_card_json(self, retry_timeout=5) -> None: "cards_loaded" ) # Dispatches the event that the cards are loaded - async def _post_guild_count(self) -> None: + async def _post_guild_count(self) -> None: # pragma: no cover """Posts relevant stats to the botlists Killua is on""" await self.client.session.post( f"https://discordbotlist.com/api/v1/bots/756206646396452975/stats", headers={"Authorization": DBL_TOKEN}, data={"guilds": len(self.client.guilds)}, ) - await self.client.session.post( - f"https://top.gg/api/bots/756206646396452975/stats", - headers={"Authorization": "Bearer " + TOPGG_TOKEN}, - data={"server_count": len(self.client.guilds)}, + await post_metrics( + self.client.session, + server_count=len(self.client.guilds), + shard_count=self.client.shard_count or 0, ) async def _load_cards_cache(self) -> None: @@ -179,13 +180,11 @@ def print_dev_text(self) -> None: f"{PrintColors.OKGREEN}Running bot in dev enviroment...{PrintColors.ENDC}" ) - async def cog_load(self): + async def cog_load(self): # pragma: no cover await self._insert_const_to_db() await self._set_patreon_banner() if self.client.is_dev: self.print_dev_text() - else: - await self._post_guild_count() @commands.Cog.listener() async def on_card_cache_loaded(self): @@ -193,7 +192,7 @@ async def on_card_cache_loaded(self): await self._load_cards_cache() @commands.Cog.listener() - async def on_ready(self): + async def on_ready(self): # pragma: no cover if len(Card.raw) == 0: await self._initialize_card_json() if not self.save_guilds.is_running() and not self.client.is_dev: @@ -222,9 +221,12 @@ async def on_ready(self): if (await DB.const.find_one({"_id": "migrate"}))["value"]: await migrate_requiring_bot(self.client) await DB.const.update_one({"_id": "migrate"}, {"$set": {"value": False}}) + if not self.client.is_dev and not self._initial_stats_posted: + await self._post_guild_count() + self._initial_stats_posted = True @tasks.loop(hours=12) - async def status(self): + async def status(self): # pragma: no cover await self.client.update_presence() # For some reason this does not work in cog_load because it always fires before the bot is connected and # thus throws an error so I have to do it a bit more hacky in here if not self.client.is_dev: @@ -240,7 +242,7 @@ def _date_helper(self, hour: int) -> int: return hour @tasks.loop(minutes=1) - async def vote_reminders(self): + async def vote_reminders(self): # pragma: no cover try: enabled = DB.teams.find({"voting_reminder": True}) async for user in enabled: @@ -301,7 +303,7 @@ async def vote_reminders(self): ) @tasks.loop(hours=24) - async def save_guilds(self): + async def save_guilds(self): # pragma: no cover from killua.static.constants import daily_users # Arguably this is a much better way of doing this as otherwise diff --git a/killua/cogs/games.py b/killua/cogs/games.py index d8ad24e2f..b85327b9f 100644 --- a/killua/cogs/games.py +++ b/killua/cogs/games.py @@ -79,7 +79,9 @@ async def _wait_for_dm_response( data.append((msg, view)) done, pending = await asyncio.wait( - [v.wait() for _, v in data], return_when=asyncio.ALL_COMPLETED, timeout=100 + [asyncio.create_task(v.wait()) for _, v in data], + return_when=asyncio.ALL_COMPLETED, + timeout=100, ) for m, v in data: diff --git a/killua/cogs/help.py b/killua/cogs/help.py index 37be36e15..63fb0016e 100644 --- a/killua/cogs/help.py +++ b/killua/cogs/help.py @@ -364,8 +364,8 @@ async def _help_single_argument(self, ctx: commands.Context, group: str, prefix: ) return await paginator.start() - # If the group doesn't exist, check if it's a command - return await self._handle_command_help(ctx, group, prefix) + # If the group doesn't exist, treat the token as a command name (qualified or name). + return await self._help_command_argument(ctx, group, prefix) @commands.hybrid_command(usage="help [group] [command]", extras={"id": 45}) diff --git a/killua/cogs/premium.py b/killua/cogs/premium.py index 9dd856091..e2ef572a7 100644 --- a/killua/cogs/premium.py +++ b/killua/cogs/premium.py @@ -344,7 +344,7 @@ async def guild(self, ctx: commands.Context, action: Literal["add", "remove"]): if ( len(user.premium_guilds.keys()) - > PATREON_TIERS[user.premium_tier]["premium_guilds"] + >= PATREON_TIERS[user.premium_tier]["premium_guilds"] ): return await ctx.send( "You first need to remove premium perks from one of your other servers to give this server premium status" diff --git a/killua/cogs/shop.py b/killua/cogs/shop.py index 7b423e07b..10b3c9ad7 100644 --- a/killua/cogs/shop.py +++ b/killua/cogs/shop.py @@ -37,7 +37,7 @@ def __init__(self, *args, **kwargs): Button(label="Menu", style=discord.ButtonStyle.blurple, custom_id="menu") ) - async def start(self): + async def start(self): # pragma: no cover view = await self._start() if view.ignore: @@ -108,7 +108,7 @@ async def on_cards_loaded(self): self.cards_shop_update.start() @tasks.loop(hours=6) - async def cards_shop_update(self): + async def cards_shop_update(self): # pragma: no cover # There have to be 4-5 shop items, inserted into the db as a list with the card numbers # the challenge is to create a balanced system with good items rare enough but not too rare try: @@ -502,7 +502,7 @@ async def _todo_buy_space(self, ctx: commands.Context, todo_list: TodoList, user """Handles buying more space for a todo list""" if user.jenny < (todo_list.spots * 100 * 0.5): return await ctx.send( - f"You don't have enough Jenny to buy more space for your todo list. You need {todo_list['spots']*100} Jenny" + f"You don't have enough Jenny to buy more space for your todo list. You need {int(todo_list.spots * 100 * 0.5)} Jenny" ) if todo_list.spots >= 100: diff --git a/killua/cogs/small_commands.py b/killua/cogs/small_commands.py index 07221189b..91b808f4b 100644 --- a/killua/cogs/small_commands.py +++ b/killua/cogs/small_commands.py @@ -477,9 +477,9 @@ async def calc(self, ctx: commands.Context, *, expression: str = None): ) answer = await r.json() - if "error" not in answer or "result" not in answer: + if not isinstance(answer, dict) or "result" not in answer: return await ctx.send("An unknown error occurred during calculation!") - if answer["error"]: + if answer.get("error"): return await ctx.send( "The following error occurred while calculating:\n`{}`".format( answer["error"] diff --git a/killua/cogs/tags.py b/killua/cogs/tags.py index 23e554e90..1afcefc6c 100644 --- a/killua/cogs/tags.py +++ b/killua/cogs/tags.py @@ -98,19 +98,17 @@ class Member: @classmethod async def new(cls, user_id: int, guild_id: int) -> "Member": - raw = (await Guild.new(guild_id)).tags - if raw is None: - return Member(has_tags=False) - if "tags" not in raw: + tag_list = (await Guild.new(guild_id)).tags + if not tag_list: return Member(has_tags=False) - tags: list = raw["tags"] - if user_id not in [r["owner"] for r in tags]: + if user_id not in [r["owner"] for r in tag_list]: return Member(has_tags=False) owned_tags: list = [] - for x in tags: - owned_tags.append([x["name"], [x["uses"]]]) + for x in tag_list: + if x["owner"] == user_id: + owned_tags.append([x["name"], x["uses"]]) return Member(has_tags=True, tags=owned_tags) @@ -297,7 +295,7 @@ async def edit(self, ctx: commands.Context, *, name: str): if (error := Tags._validate_tag_details(name, content)): return await ctx.send(error) - await tag.update(content) + await tag.update("content", content) return await ctx.send(f"Successfully updated tag `{tag.name}`", allowed_mentions=discord.AllowedMentions.none()) @check() diff --git a/killua/cogs/todo.py b/killua/cogs/todo.py index 15e8f3ae2..6ff93815b 100644 --- a/killua/cogs/todo.py +++ b/killua/cogs/todo.py @@ -634,7 +634,7 @@ async def remove(self, ctx: commands.Context, todo_numbers: commands.Greedy[int] if len(todo_numbers) == len(failed): return await ctx.send("All inputs are invalid task ids. Please try again.") - todo_list.set_property("todos", todos) + await todo_list.set_property("todos", todos) return await ctx.send( f"You removed todo number{'s' if len(todo_numbers) > 1 else ''} {', '.join([str(x) for x in todo_numbers])} successfully" + ( @@ -804,14 +804,14 @@ async def kick(self, ctx: commands.Context, user: discord.User): ) if user.id in todo_list.editor: - todo_list.kick_editor(user.id) + await todo_list.kick_editor(user.id) await ctx.send( f"You have successfully taken the editor permission from {user}", allowed_mentions=discord.AllowedMentions.none(), ) if user.id in todo_list.viewer: - todo_list.kick_viewer(user.id) + await todo_list.kick_viewer(user.id) await ctx.send( f"You have successfully taken the viewer permission from {user}", allowed_mentions=discord.AllowedMentions.none(), @@ -961,7 +961,7 @@ async def invite( if role == "editor": if user.id in todo_list.viewer: - todo_list.kick_viewer( + await todo_list.kick_viewer( user.id ) # handled like a promotion and exchanges viewer perms for edit perms await todo_list.add_editor(user.id) @@ -1018,13 +1018,13 @@ async def assign(self, ctx: commands.Context, todo_number: int, user: discord.Us except discord.Forbidden: pass todos[todo_number - 1]["assigned_to"].remove(user.id) - todo_list.set_property("todos", todos) + await todo_list.set_property("todos", todos) return await ctx.send( f"Successfully removed assignment of todo task {todo_number} of {user}" ) todos[todo_number - 1]["assigned_to"].append(user.id) - todo_list.set_property("todos", todos) + await todo_list.set_property("todos", todos) if ctx.author != user: embed = discord.Embed.from_dict( @@ -1081,7 +1081,7 @@ async def reorder(self, ctx: commands.Context, position: int, new_position: int) ) todo_list.todos.insert(new_position - 1, todo_list.todos.pop(position - 1)) - todo_list.set_property("todos", todo_list.todos) + await todo_list.set_property("todos", todo_list.todos) return await ctx.send( f"Successfully reordered todo task {position} to position {new_position}" ) diff --git a/killua/static/cards.py b/killua/static/cards.py index 33884df8f..d12aa2792 100644 --- a/killua/static/cards.py +++ b/killua/static/cards.py @@ -473,7 +473,7 @@ async def exec(self, effect: str, card_id: int) -> None: if effect.lower() not in ["list", "analysis", "1031", "1038"]: raise CheckFailure( - f"Invalid effect to use! You can use either `analysis` or `list` with this card. Usage: `{await self.client.command_prefix(self.client, self.ctx.message)[2]}use {self.id} `" + f"Invalid effect to use! You can use either `analysis` or `list` with this card. Usage: `{(await cast(BaseBot, self.ctx.bot).command_prefix(self.ctx.bot, self.ctx.message))[2]}use {self.id} `" ) if effect.lower() in ["list", "1038"]: diff --git a/killua/static/constants.py b/killua/static/constants.py index 7a410905a..345b0ceaf 100644 --- a/killua/static/constants.py +++ b/killua/static/constants.py @@ -43,6 +43,7 @@ def __get__(self, obj, objtype=None) -> T: class DB: _DB = None + _test_const_seeded: bool = False def __init__(self): if not args.Args.test: @@ -64,8 +65,11 @@ def todo(self) -> Union[AsyncCollection, Database]: def const(self) -> Union[AsyncCollection, Database]: if args.Args.test is not None: db = Database("const") - db.insert_many(CONST_DEFAULT) + if not DB._test_const_seeded: + from copy import deepcopy + Database.db["const"] = [deepcopy(d) for d in CONST_DEFAULT] + DB._test_const_seeded = True return db else: return self._DB["const"] diff --git a/killua/tests/README.md b/killua/tests/README.md index 0234e5a83..e10f81796 100644 --- a/killua/tests/README.md +++ b/killua/tests/README.md @@ -1,5 +1,6 @@ -# ⛔️ THIS SYSTEM IS NOT FULLY IMPLEMENTED YET -The codebase has moved on since the initial batch of tests. This system needs a bunch of rewriting before being viable again. +# Killua test system + +Integration tests run offline via `python -m killua -t` (see [COVERAGE_AUDIT.md](COVERAGE_AUDIT.md) for scope, gaps, and the **70%** coverage gate). CI uses **Python 3.13** ([`.github/workflows/python-tests.yml`](../../.github/workflows/python-tests.yml)). # Explanation of how testing works with Killua @@ -9,6 +10,10 @@ Index [How tests are written](#how-tests-are-written) +[Testing Views and component interactions](#testing-views-and-component-interactions) + +[Coverage audit and games DM notes](#coverage-audit-and-games-dm-notes) + [Troubleshooting](#troubleshooting) ## Design @@ -16,7 +21,7 @@ Index In general, testing a command works by controlling everything *but* the command callback itself. That means of all relevant discord objects there exists a class in `killua/tests/types`, mocking their methods and attributes that are used inside of the commands. Their `__class__` is set to the discord class they mock to avoid an `isinstance(argument, discordClass)` inside of a command falsely failing on a mock class. -There also exist mock classes for pymongo database stuff in a different file location as the tests are designed to be able to work completely offline (which is currently not fully archived) and the database should not be spammed when all tests are run. When tests are run, the `DB` class containing all details of connections will automatically switch to the mock data instead of the real one. +There also exist mock classes for pymongo database stuff in [`killua/utils/test_db.py`](../utils/test_db.py). When `-t` / `--test` is passed, the `DB` class switches to in-memory `TestingDatabase` instead of MongoDB. Each test **command class** run clears that store and `User.cache` before executing (see `reset_test_fixtures` in [`fixtures.py`](fixtures.py), called from `Testing.test_command`). ### How responses can be verified @@ -29,23 +34,16 @@ This is why all messagables that *aren't* `Context` have a property referring ba Both `View` and `Bot.wait_for` normally require another user interaction for the command functioning normally. For `View` it also strongly depends on what the user does on what the commands response is. They are handled by: + `Bot.wait_for` -For this, `asyncio.wait` is used to call the command and a method of `Bot` that resolves the `wait_for` at the same time. +For this, `asyncio.wait` is used with **`asyncio.create_task`** (required since Python 3.13) to run the command and a method of `Bot` that resolves the `wait_for` at the same time. ```py -asyncio.wait({command(context), Bot.resolve("message", MockMessage())}) +await asyncio.wait({ + asyncio.create_task(command(context)), + asyncio.create_task(Bot.resolve("message", MockMessage())), +}) ``` + `View`s -Views were harder to tackle as they are much more complex in what could be responded to them. How it was solved is by before calling the command callback, the method `respond_to_view` of the mock `Context` supplied can be set which takes in one parameter `context`. Through `context.current_view` it can then access the view and go through it's `children` to modify values and call callbacks. -`respond_to_view` overwrites `View.wait()` if a `View` is supplied to a `send` method and so it will be called if the view requires a response, also avoiding the trouble of the tests taking much longer if the view had to be responded to from a separate asyncio loop. In code, this would simply look like this: -```py -async def respond_to_view_no_settings_changed(context: Context): - for child in context.current_view.children: - if child.custom_id == "save": - await child.callback(MockInteraction(context)) - -context.respond_to_view = respond_to_view_no_settings_changed -await command(context) -``` +When a command `await ctx.send(..., view=some_view)`, `TestingContext.send` stores the view on `context.current_view` and replaces `view.wait` with `context.respond_to_view`. After the command body returns, your test callback runs **in the same coroutine** (no real 15-minute Discord timeout). See [Testing Views and component interactions](#testing-views-and-component-interactions) for Path A vs Path B and full examples. ### UML Diagram of Testing structure @@ -57,10 +55,9 @@ This way, commands can be dynamically found from methods defined in the base cla ### Logging -The system also uses the `logging` module instead of printing results. This leads to the output level being configurable with the command line argument for testing (`python3 -m killua -t `) defaulting to `INFO`. +The system uses the `logging` module instead of printing results. Set level with `-l` / `--log` (default `INFO`), e.g. `python -m killua -t -l DEBUG`. -> **Warning** -> Due to a fix to an issue with assertion errors displaying even though they are matched, **all console output without `INFO:`, `DEBUG:`, `WARNING:`, `ERROR:` or `CRITICAL:` will not be printed to the console.** So for debugging either use `logging.debug` or `print("DEBUG:", thing)` +For local debugging, use `logging.debug(...)` or prefix prints with a logging level if you rely on structured log output. A stderr filter (`DevMod`) that hid non-log lines exists in [`tests/__init__.py`](__init__.py) but is **disabled**; assertion tracebacks are controlled via `SUPPRESS_TEST_TRACEBACKS` when using `--json`. ### Assertion checks @@ -106,14 +103,188 @@ async def should_work(self): # It is important to place whatever variable to test again after the comma so if it fails, # the actual value of that variable can be displayed in the logs ``` -For writing tests including `Views`s or `Bot.wait_for` see [How `View`s and `Bot.wait_for` is handled](#how-views-and-bot.wait_for-is-handled) +For writing tests including `Views` or `Bot.wait_for`, see [How `View`s and `Bot.wait_for` is handled](#how-views-and-botwait_for-is-handled) and [Testing Views and component interactions](#testing-views-and-component-interactions). + +### Testing Views and component interactions + +Killua uses Discord UI in two different ways. Tests mirror that split instead of stubbing “the user clicked something.” + +| Production pattern | Who handles the click | Test path | Interaction type | +|--------------------|----------------------|-----------|-------------------| +| Command sends a `discord.ui.View` and `await view.wait()` | Button/Select **callbacks** on that view | **Path A** | `ArgumentInteraction` | +| Persistent listener on the cog (`on_interaction`) | `Events.on_interaction` (poll/WYR votes, etc.) | **Path B** | `MockComponentInteraction` | +| Command sends a view in a **DM** (`Member.send`) | Same as Path A, but `wait` is triggered from patched `send` | **Path A (DM)** | `ArgumentInteraction` via `member_dm` | + +Both paths funnel replies into the same place: `context.result.message` (content, embeds, components), so assertions stay identical to non-interactive commands. + +#### End-to-end flow (Path A — command-owned view) + +Typical case: `cards use` confirm (1026), defense select, paginator, `actions settings` select. + +```mermaid +sequenceDiagram + participant Test + participant Cmd as command callback + participant Ctx as TestingContext + participant View as discord.ui.View + participant CB as button/select.callback + + Test->>Test: set respond_to_view (or context manager) + Test->>Cmd: await command(ctx) + Cmd->>Ctx: send(embed, view=View) + Ctx->>Ctx: current_view = View + Ctx->>Ctx: View.wait = respond_to_view + Cmd->>View: await view.wait() + View->>Test: respond_to_view(ctx) + Test->>CB: await child.callback(ArgumentInteraction) + CB->>Ctx: interaction.response → send/edit + Test->>Test: assert ctx.result.message +``` + +**Minimal example** (settings save button): + +```py +from ..types import ArgumentInteraction +from ..harnesses import find_button, respond_to_view + +async def press_save(ctx): + btn = find_button(ctx.current_view, custom_id="save") + await btn.callback(ArgumentInteraction(ctx)) + +with respond_to_view(self.base_context, press_save): + await self.command(self.cog, self.base_context) + +assert "saved" in self.base_context.result.message.content +``` + +**Confirm / cancel** — reuse `Testing.press_confirm` / `press_cancel` as the `respond_to_view` callback (`cards_use_spells.py`, shop buy): + +```py +from ..harnesses import respond_to_view + +self.base_context.timeout_view = False # True → instant on_timeout (1026 timeout test) +with respond_to_view(self.base_context, Testing.press_confirm): + await invoke_use(self, 1026) +``` + +**Paginator** — do not hand-roll `custom_id` strings; use `press_paginator_button` so the real `Buttons` callback runs: + +```py +from ..harnesses import embed_footer_page, press_paginator_button + +await self.command(self.cog, self.base_context, ...) +view = self.base_context.current_view +msg = self.base_context.result.message +before = embed_footer_page(msg.embeds[0]) +await press_paginator_button( + view, "next", context=self.base_context, message=msg +) +after = embed_footer_page(self.base_context.result.message.embeds[0]) +assert after[0] == before[0] + 1 +``` + +**Defense select** (attack flow waits on target’s view) — `spell_use.respond_defense_with_spell` finds the `Select` and passes `data={"values": [str(spell_id)]}`: + +```py +from ..harnesses import respond_defense_with_spell, run_attack_against_defender + +# run_attack_against_defender sets respond_to_view to respond_defense_with_spell internally +await run_attack_against_defender(self, defense_id=1003, use_defense=True) +``` + +Helpers: `harnesses/views.py` (`find_button`, `find_select`), `harnesses/context.py` (`respond_to_view` context manager restores the previous callback). + +#### End-to-end flow (Path B — cog `on_interaction`) + +Poll and WYR votes are **not** wired through `view.wait()` on the command that created the message. `Events.on_interaction` reads `interaction.data["custom_id"]`, edits the embed, and may write `guild.polls`. Tests build a **message-shaped fixture** (embed + fake component rows) and call the listener directly. + +```mermaid +sequenceDiagram + participant Test + participant Msg as fixture message + participant IX as MockComponentInteraction + participant Ev as Events.on_interaction + + Test->>Msg: build_poll_message / build_wyr_message + Test->>IX: custom_id, user, message, context + Test->>Ev: await on_interaction(ix) + Ev->>IX: response.send_message / edit_message + IX->>Test: updates ctx.result or message.embeds + Test->>Test: assert DB / custom_id / embed fields +``` + +**Example** (sixth voter → encrypted option `custom_id`; see `events.py` + `poll_wyr.py`): + +```py +from ..harnesses import ( + build_poll_message, + cast_vote, + encrypted_tail_on_button, + option_button_custom_id, +) + +msg = build_poll_message( + self.base_author.id, + option_index=1, + visible_voter_ids=[v1, v2, v3, v4, v5], # 5 names in embed → next vote encrypts +) +cid_before = option_button_custom_id(msg, option_index=1) +ix = await cast_vote( + self.cog, + context=self.base_context, + message=msg, + voter=DiscordMember(id=new_voter_id, guild=self.base_guild), + custom_id=cid_before, +) +assert ix.response.is_done() +# After vote, re-read button custom_id from updated message components +tail = encrypted_tail_on_button(updated_cid, "poll:opt-1:") +assert len(tail) > 0 +``` + +`MockComponentInteraction` (`harnesses/interaction.py`) implements enough of `discord.Interaction` for listeners: `type`, `data`, `user`, `message`, `response.send_message` / `edit_message`, `followup`, and `original_response`. It is **not** used for Path A view callbacks — those use `ArgumentInteraction` in `types/interaction.py`, which ties `response.send_message` back to `context.send` and keeps `current_view` in sync. + +#### Path A in DMs (`member_dm`) + +RPS PvP/PvE: the command does not attach `respond_to_view` on the guild context for the DM select. Instead, `patch_member_rps_select` wraps `Member.send`, builds a `Message`, and replaces `view.wait` so `RpsSelect.callback(ArgumentInteraction(...))` runs with a chosen `data["values"]`. Do **not** stub `_wait_for_dm_response` if you want this wiring tested (see `games.py`). + +#### Stubs vs real callbacks + +Use **stubs** (`patch`, `AsyncMock`) for boundaries: HTTP, `random.choice`, `asyncio.sleep`, huge `channel.history`. Use **Path A/B** when the test subject is UI wiring (wrong `custom_id`, double `response`, select values, timeout). Stubbing `_wait_for_defense` or `_wait_for_dm_response` only checks downstream logic, not that the right component fired. + +#### Paginator / help caveats + +Some paginator subclasses (`HelpPaginator`, `ShopPaginator`, …) delete the message and re-invoke the command when `start()` finishes. For **pagination-only** tests, temporarily replace `Subclass.start` with `killua.utils.paginator.Paginator.start` so the test stops after `view.wait()` without follow-up navigation. For `get_group_help` / `get_formatted_commands`, the test bot may not register every production command; you can `patch.object(bot, "walk_commands", return_value=[...])` with minimal command-like objects (real `callback.__code__` for `find_source`) to exercise embed and footer formatting. + +## Line coverage (`coverage.py`) + +Runtime line coverage for almost all of `killua/` (see [`.coveragerc`](../../.coveragerc)). Install once: `pip install -r requirements-dev.txt`. **Gate:** `fail_under = 70` on the statement-weighted total. + +```bash +coverage run -m killua -t # full suite +coverage report # fails if total < 70% +coverage run -m killua -t games # one cog group +coverage html # open htmlcov/index.html +``` + +See [COVERAGE_AUDIT.md](COVERAGE_AUDIT.md) for scope, gaps, and command-scenario notes. + +## Coverage audit and games DM notes + +- **Living matrix**: [COVERAGE_AUDIT.md](COVERAGE_AUDIT.md) lists games / cards / todo / economy / moderation coverage and **exit criteria** for high-value commands. +- **Games — DM flows**: use [`harnesses.member_dm.patch_member_rps_select`](harnesses/member_dm.py) on `Member.send` so `_wait_for_dm_response` completes via real `RpsSelect` callbacks (see `games.py` PvP/PvE tests). Do not stub `_wait_for_dm_response`. +- **Cards spell `use`**: import from [`harnesses`](harnesses/) — `invoke_use`, `assert_steal_succeeded`, `setup_met_view_spell`, `respond_to_view`, etc. See `groups/cards_use_spells.py`. +- **Poll / WYR votes**: use [`harnesses/poll_wyr.py`](harnesses/poll_wyr.py) to build messages with 5+ embed voters and assert encrypted `custom_id` tails or premium `guild.polls` updates via `Events.on_interaction`. +- **DM confirms** (e.g. todo invite): [`harnesses/dm_view.patch_user_confirm_dm`](harnesses/dm_view.py). ## Troubleshooting -### `Views` -Commonly view responses cause a bit of trouble, though luckily not as much as they used to. When a view response isn't as intended, check the following: -+ Is `context.timeout_view` set to `True`? If it is, the view automatically instantly times out -+ Is the callback you designed the view for the last time the callback is used? +### `Views` and interactions +When a view or listener test misbehaves, see [Testing Views and component interactions](#testing-views-and-component-interactions) first. Quick checks: ++ **Path A**: Is `timeout_view` accidentally `True`? Is `respond_to_view` set *before* `await command(...)` (or inside the `respond_to_view` context manager)? ++ **Path A**: Are you calling `ArgumentInteraction` on a **view child** callback, not `MockComponentInteraction`? ++ **Path B**: Does the fixture `message` have `components` / `embeds` the listener expects? Does `custom_id` match production (`poll:opt-1:`, `wyr:opt-b:`, …)? ++ **Path B**: Did the listener call `interaction.response`? Assert `ix.response.is_done()` after `on_interaction`. ### An issue with `User` diff --git a/killua/tests/__init__.py b/killua/tests/__init__.py index d7b423b36..1ba1a17b1 100644 --- a/killua/tests/__init__.py +++ b/killua/tests/__init__.py @@ -1,4 +1,8 @@ -import logging, sys +import json +import logging +import sys + +from . import config from .groups import tests from .types import Bot from ..static.enums import PrintColors @@ -6,7 +10,7 @@ # CAREFUL. This is a fairly hacky fix as assertion erros still get printed even though they are caught for some reason. -# With this, only logging messages get printed. HOWEVER this means all errors that happen outside of tests will also not get printed. +# With this, only logging messages get printed. HOWEVER this means all errors that happen outside of tests will also not be printed. # If you get no output at all and nothing is happening, comment this section out. class DevMod: def write(self, msg): @@ -15,193 +19,55 @@ def write(self, msg): sys.__stderr__.write(msg) -async def run_tests(args) -> None: - # sys.stderr = DevMod() +async def _close_test_bot_session() -> None: + """TestingBot.setup_hook creates an aiohttp ClientSession; close it after the suite.""" + session = getattr(Bot, "session", None) + if session is None or getattr(session, "closed", True): + return + await session.close() - Bot.command_prefix = lambda *_: ["mention1", "mention2", "k!"] - await Bot.setup_hook() - start = datetime.now() - if args: - if len(args) == 1: - for group in tests: - if ( - group.__name__.replace("Testing", "").lower() == args[0].lower() - ): # If the argument was only a specific group/cog - logging.info( - PrintColors.OKCYAN - + f"Testing group {group.__name__.replace('Testing', '')}..." - + PrintColors.ENDC - ) - result = await group().run_tests() - logging.info( - PrintColors.OKCYAN - + f"Test results to test group {group.__name__.replace('Testing', '')}:" - + PrintColors.ENDC - ) - if len(result.failed) == 0 and len(result.errored) == 0: - logging.info( - PrintColors.OKGREEN - + f"All ({len(result.passed)}) tests passed \U00002713" - + PrintColors.ENDC - ) - else: - logging.info( - PrintColors.OKGREEN - + f"{len(result.passed)} tests passed \U00002713" - + PrintColors.ENDC - ) - logging.info( - PrintColors.WARNING - + f"{len(result.failed)} tests failed \U00002715" - + PrintColors.ENDC - ) - logging.info( - PrintColors.FAIL - + f"{len(result.errored)} tests raised unhandled exceptions \U000026a0" - + PrintColors.ENDC - ) - logging.info( - PrintColors.OKCYAN - + "Tests finished after: " - + PrintColors.OKBLUE - + f"{round((datetime.now() - start).total_seconds())}" - + PrintColors.OKCYAN - + " seconds" - + PrintColors.ENDC - ) - return +def _exit_code_for_result(tr) -> int: + return 1 if (tr.failed or tr.errored) else 0 - sys.stderr = sys.__stderr__ # Making sure the error is displayed - raise ValueError( - f"Invalid argument: {args[0]}. Make sure to provide a valid group/cog name." - ) - else: # Both a cog and command are supplied so only the command is tested - for group in tests: - if group.__name__.replace("Testing", "").lower() == args[0].lower(): - logging.info( - PrintColors.OKCYAN - + f"Testing command {args[1]} of group {group.__name__.replace('Testing', '')}..." - + PrintColors.ENDC - ) - result = await group().run_tests(args[1]) - logging.info( - PrintColors.OKCYAN - + f"Test results to test command {args[1]} of group {group.__name__.replace('Testing', '')}:" - + PrintColors.ENDC - ) - if len(result.failed) == 0 and len(result.errored) == 0: - logging.info( - PrintColors.OKGREEN - + f"All ({len(result.passed)}) tests passed \U00002713" - + PrintColors.ENDC - ) - else: - logging.info( - PrintColors.OKGREEN - + f"{len(result.passed)} tests passed \U00002713" - + PrintColors.ENDC - ) - logging.info( - PrintColors.WARNING - + f"{len(result.failed)} tests failed \U00002715" - + PrintColors.ENDC - ) - logging.info( - PrintColors.FAIL - + f"{len(result.errored)} tests raised unhandled exceptions \U000026a0" - + PrintColors.ENDC - ) - logging.info( - PrintColors.OKCYAN - + "Tests finished after: " - + PrintColors.OKBLUE - + f"{round((datetime.now() - start).total_seconds())}" - + PrintColors.OKCYAN - + " seconds" - + PrintColors.ENDC - ) - return +def _print_json_report(payload: dict, json_output: bool) -> None: + if json_output: + print(json.dumps(payload, indent=2), flush=True) - sys.stderr = sys.__stderr__ # Making sure the error is displayed - raise ValueError( - f"Invalid arguments: {' '.join(args)}. Make sure to provide a valid group/cog and command." - ) - total = {"passed": [], "failed": [], "errors": []} +def _group_name(group) -> str: + return group.__name__.replace("Testing", "") - for group in tests: - logging.info( - PrintColors.OKCYAN - + f"Testing group {group.__name__.replace('Testing', '')}..." - + PrintColors.ENDC - ) - result = await group().run_tests() - logging.info( - PrintColors.OKCYAN - + f"Test results to test group {group.__name__.replace('Testing', '')}:" - + PrintColors.ENDC - ) - if len(result.failed) == 0 and len(result.errored) == 0: - logging.info( - PrintColors.OKGREEN - + f"All ({len(result.passed)}) tests passed \U00002713" - + PrintColors.ENDC - ) - else: - logging.info( - PrintColors.OKGREEN - + f"{len(result.passed)} tests passed \U00002713" - + PrintColors.ENDC - ) - logging.info( - PrintColors.WARNING - + f"{len(result.failed)} tests failed \U00002715" - + PrintColors.ENDC - ) - logging.info( - PrintColors.FAIL - + f"{len(result.errored)} tests raised unhandled exceptions \U000026a0" - + PrintColors.ENDC - ) - - total["passed"].extend(result.passed) - total["failed"].extend(result.failed) - total["errors"].extend(result.errored) - del group - - sys.stderr = sys.__stderr__ # Reset stderr to default in case an error happens here - logging.info(PrintColors.OKCYAN + "Total test results:" + PrintColors.ENDC) - if len(total["failed"]) == 0 and len(total["errors"]) == 0: +def _log_result_counts( + passed_count: int, failed_count: int, errored_count: int +) -> None: + if failed_count == 0 and errored_count == 0: logging.info( PrintColors.OKGREEN - + f"All ({len(total['passed'])}) tests passed \U00002713" + + f"All ({passed_count}) tests passed \U00002713" + PrintColors.ENDC ) else: logging.info( PrintColors.OKGREEN - + f"{len(total['passed'])} tests passed \U00002713" + + f"{passed_count} tests passed \U00002713" + PrintColors.ENDC ) logging.info( PrintColors.WARNING - + f"{len(total['failed'])} tests failed \U00002715" + + f"{failed_count} tests failed \U00002715" + PrintColors.ENDC ) logging.info( PrintColors.FAIL - + f"{len(total['errors'])} tests raised unhandled exceptions \U000026a0" + + f"{errored_count} tests raised unhandled exceptions \U000026a0" + PrintColors.ENDC ) - # for failed in total["errors"]: - # print(PrintColors.FAIL + f"Errored test: {failed['command'].__name__}" + PrintColors.ENDC) - # print(PrintColors.FAIL + f"Error: {failed['error'].error}" + PrintColors.ENDC) - # for failed in total["failed"]: - # print(PrintColors.WARNING + f"Failed test: {failed['command'].__name__}" + PrintColors.ENDC) - # print(PrintColors.WARNING + f"Result: {failed['result'].error}" + PrintColors.ENDC) + + +def _log_elapsed(start: datetime) -> None: logging.info( PrintColors.OKCYAN + "Tests finished after: " @@ -211,3 +77,105 @@ async def run_tests(args) -> None: + " seconds" + PrintColors.ENDC ) + + +def _log_run_heading(heading: str) -> None: + logging.info(PrintColors.OKCYAN + heading + PrintColors.ENDC) + + +async def run_tests(args, *, json_output: bool = False) -> int: + # sys.stderr = DevMod() + + async def _test_prefix(*_): + return ["mention1", "mention2", "k!"] + + Bot.command_prefix = _test_prefix + await Bot.setup_hook() + if json_output: + config.SUPPRESS_TEST_TRACEBACKS = True + root = logging.getLogger() + root.handlers = [logging.NullHandler()] + root.setLevel(logging.CRITICAL) + try: + start = datetime.now() + + if args: + if len(args) == 1: + for group in tests: + if _group_name(group).lower() == args[0].lower(): + gname = _group_name(group) + _log_run_heading(f"Testing group {gname}...") + result = await group().run_tests() + payload = {gname: dict(result.by_command)} + code = _exit_code_for_result(result) + _log_run_heading(f"Test results to test group {gname}:") + _log_result_counts( + len(result.passed), + len(result.failed), + len(result.errored), + ) + _log_elapsed(start) + _print_json_report(payload, json_output) + return code + + sys.stderr = sys.__stderr__ # Making sure the error is displayed + raise ValueError( + f"Invalid argument: {args[0]}. Make sure to provide a valid group/cog name." + ) + + for group in tests: + if _group_name(group).lower() == args[0].lower(): + gname = _group_name(group) + _log_run_heading( + f"Testing command {args[1]} of group {gname}..." + ) + result = await group().run_tests(args[1]) + payload = {gname: dict(result.by_command)} + code = _exit_code_for_result(result) + _log_run_heading( + f"Test results to test command {args[1]} of group {gname}:" + ) + _log_result_counts( + len(result.passed), + len(result.failed), + len(result.errored), + ) + _log_elapsed(start) + _print_json_report(payload, json_output) + return code + + sys.stderr = sys.__stderr__ # Making sure the error is displayed + raise ValueError( + f"Invalid arguments: {' '.join(args)}. Make sure to provide a valid cog and command." + ) + + total = {"passed": [], "failed": [], "errors": []} + json_payload = {} + + for group in tests: + gname = _group_name(group) + _log_run_heading(f"Testing group {gname}...") + result = await group().run_tests() + json_payload[gname] = dict(result.by_command) + _log_run_heading(f"Test results to test group {gname}:") + _log_result_counts( + len(result.passed), len(result.failed), len(result.errored) + ) + + total["passed"].extend(result.passed) + total["failed"].extend(result.failed) + total["errors"].extend(result.errored) + del group + + sys.stderr = sys.__stderr__ # Reset stderr to default in case an error happens here + logging.info(PrintColors.OKCYAN + "Total test results:" + PrintColors.ENDC) + _log_result_counts( + len(total["passed"]), len(total["failed"]), len(total["errors"]) + ) + _log_elapsed(start) + suite_code = 1 if (total["failed"] or total["errors"]) else 0 + _print_json_report(json_payload, json_output) + return suite_code + finally: + config.SUPPRESS_TEST_TRACEBACKS = False + await _close_test_bot_session() diff --git a/killua/tests/config.py b/killua/tests/config.py new file mode 100644 index 000000000..b2a7322ab --- /dev/null +++ b/killua/tests/config.py @@ -0,0 +1,4 @@ +"""Runtime toggles for the test harness (kept tiny to avoid import cycles).""" + +# When True, assertion/other failures in @test methods skip traceback.print_tb to stderr. +SUPPRESS_TEST_TRACEBACKS: bool = False diff --git a/killua/tests/fixtures.py b/killua/tests/fixtures.py new file mode 100644 index 000000000..d0ae2c95b --- /dev/null +++ b/killua/tests/fixtures.py @@ -0,0 +1,21 @@ +"""Shared fixture reset for the integration test suite.""" + +from __future__ import annotations + +from killua.static.constants import DB +from killua.utils.test_db import TestingDatabase + + +def reset_test_fixtures() -> None: + """Reset in-memory DB, user cache, and bot flags between test command classes.""" + TestingDatabase.reset_all() + DB._test_const_seeded = False + + from killua.utils.classes import User + + User.cache.clear() + + from .types import Bot + + Bot.fail_timeout = False + Bot.run_in_docker = False diff --git a/killua/tests/groups/__init__.py b/killua/tests/groups/__init__.py index 800932907..08ffb48ef 100644 --- a/killua/tests/groups/__init__.py +++ b/killua/tests/groups/__init__.py @@ -1,7 +1,45 @@ from .actions import TestingActions +from .api import TestingApi +from .bot_cov import TestingBotCov from .cards import TestingCards +from .deep_coverage import TestingDeep from .dev import TestingDev +from .economy import TestingEconomy +from .events import TestingEvents +from .games import TestingGames +from .help import TestingHelp +from .image_manipulation import TestingImageManipulation +from .moderation import TestingModeration +from .premium import TestingPremium +from .prometheus_cov import TestingPrometheus +from .shop import TestingShop +from .small_commands import TestingSmallCommands +from .tags import TestingTags +from .todo import TestingTodo +from .unit_boost import TestingUnitBoost +from .web_scraping import TestingWebScraping -tests = [TestingActions, TestingCards, TestingDev] +tests = [ + TestingActions, + TestingApi, + TestingBotCov, + TestingCards, + TestingDeep, + TestingDev, + TestingEconomy, + TestingEvents, + TestingGames, + TestingHelp, + TestingImageManipulation, + TestingModeration, + TestingPremium, + TestingPrometheus, + TestingShop, + TestingSmallCommands, + TestingTags, + TestingTodo, + TestingUnitBoost, + TestingWebScraping, +] __all__ = ["tests"] diff --git a/killua/tests/groups/actions.py b/killua/tests/groups/actions.py index 2e9ac2eeb..7d5cc9429 100644 --- a/killua/tests/groups/actions.py +++ b/killua/tests/groups/actions.py @@ -1,16 +1,46 @@ from ..types import * from ...utils.classes import * from ..testing import Testing, test -from ...cogs.actions import Actions +from ...cogs.actions import Actions, AnimeAsset, ArtistAsset from random import randrange, randint -from asyncio import wait +from asyncio import create_task, wait +from unittest.mock import patch + +from ..types.utils import get_random_discord_id +from ..harnesses import MockComponentInteraction + + +def _embed0_actions(message): + raw = message.embeds + if isinstance(raw, list) and raw: + return raw[-1] + if isinstance(raw, tuple) and raw: + inner = raw[0] + if isinstance(inner, list) and inner: + return inner[-1] + return None class TestingActions(Testing): + requires_command = True def __init__(self): super().__init__(cog=Actions) + self._mock_cog_externals() + + def _mock_cog_externals(self): + """Mocks external API calls on the cog so tests work offline""" + cog = self.cog + + async def mock_request_action(endpoint): + return AnimeAsset(url="https://example.com/test.gif", anime_name="Test Anime") + + async def mock_get_image_url(endpoint): + return ArtistAsset(url="http://localhost:6060/image/test.gif", artist=None, featured=False) + + cog.request_action = mock_request_action + cog._get_image_url = mock_get_image_url class Settings(TestingActions): @@ -50,34 +80,36 @@ async def respond_to_view_no_settings_changed(context: Context): @test async def change_one_and_save(self) -> None: + from ...static.constants import ACTIONS self.base_context.view_counter = 0 + self.base_context.timeout_view = False + + # Select every action except hug (disables hug), then save — same as production UI. + select_values = [k for k in ACTIONS.keys() if k != "hug"] async def respond_to_view_changing(context: Context): if context.view_counter > 0: await context.current_view.on_timeout() return context.current_view.stop() - context.current_view.values = [] - context.current_view.timed_out = False - + select_ix = ArgumentInteraction( + context, data={"values": select_values} + ) for child in context.current_view.children: - if child.custom_id != "save": - for option in child.options: - if option.label != "hug": - context.current_view.values.append(option.value) - + if getattr(child, "custom_id", None) == "select": + await child.callback(select_ix) + context.current_view.interaction = select_ix for child in context.current_view.children: - if child.custom_id == "save": + if getattr(child, "custom_id", None) == "save": await child.callback(ArgumentInteraction(context)) - context.view_counter += 1 # This is to make sure the test is only run once + context.view_counter += 1 self.base_context.respond_to_view = respond_to_view_changing await self.command(self.cog, self.base_context) - assert User(self.base_context.author.id).action_settings["hug"] is False, User( - self.base_context.author.id - ).action_settings["hug"] + user = await User.new(self.base_context.author.id) + assert user.action_settings["hug"] is False, user.action_settings["hug"] assert ( self.base_context.result.message.embeds ), self.base_context.result.message.embeds @@ -89,7 +121,7 @@ def __init__(self, command: str): super().__init__() self.base_context.command = self.command - self.__name__ = command # This is to identify what command it came from + self.__name__ = command @test async def no_arguments_without_yes(self) -> None: @@ -110,8 +142,8 @@ async def no_arguments_with_yes(self) -> None: ) await wait( { - self.command(self.cog, self.base_context), - Bot.resolve("message", resolving_message), + create_task(self.command(self.cog, self.base_context)), + create_task(Bot.resolve("message", resolving_message)), } ) @@ -160,30 +192,28 @@ async def multiple_members_correctly_supplied(self) -> None: @test async def single_member_action_disabled(self) -> None: member = DiscordMember(guild=self.base_guild) - await User.new(member.id).set_action_settings({self.command.name: False}) + user = await User.new(member.id) + await user.set_action_settings({self.command.name: False}) await self.command(self.cog, self.base_context, [member]) assert ( self.base_context.result.message.content - == f"**{member.display_name}** has disabled this action" + == f"All members targeted have disabled this action." ), self.base_context.result.message.content @test async def some_members_action_disabled(self) -> None: members = [ - DiscordMember(guild=self.base_guild, id=id) for id in range(randint(4, 10)) + DiscordMember(guild=self.base_guild, id=get_random_discord_id()) for _ in range(randint(4, 10)) ] disabled = 0 for p, member in enumerate(members): - if ( - p < len(members) - 1 - ): # We do not want all members to have this action disabled - await User.new(member.id).set_action_settings( - {self.command.name: False} - ) + user = await User.new(member.id) + if p < len(members) - 1: + await user.set_action_settings({self.command.name: False}) disabled += 1 else: - await User.new(member.id).set_action_settings({self.command.name: True}) + await user.set_action_settings({self.command.name: True}) await self.command(self.cog, self.base_context, members) assert ( @@ -198,12 +228,13 @@ async def some_members_action_disabled(self) -> None: async def all_members_action_disabled(self) -> None: members = [DiscordMember(guild=self.base_guild) for _ in range(randint(4, 10))] for member in members: - await User.new(member.id).set_action_settings({self.command.name: False}) + user = await User.new(member.id) + await user.set_action_settings({self.command.name: False}) await self.command(self.cog, self.base_context, members) assert ( self.base_context.result.message.content - == "All members targetted have disabled this action." + == "All members targeted have disabled this action." ), self.base_context.result.message.content @@ -231,6 +262,42 @@ class Hug(_ActionCommand): def __init__(self): super().__init__("hug") + @test + async def hug_back_button_invokes_return_hug(self) -> None: + """Path B: Actions.on_interaction when target presses Hug back (see killua/tests/component_interaction.py).""" + target = DiscordMember(guild=self.base_guild, id=self.base_author.id + 5000) + self.base_guild.members = [self.base_author, target] + await User.new(self.base_author.id) + await User.new(target.id) + + enc = Bot._encrypt(target.id) + cid = f"action:hug:{self.base_author.id}:{enc}:" + + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context, [target]) + + msg = self.base_context.result.message + assert msg.embeds, msg.embeds + + ix = MockComponentInteraction( + context=self.base_context, + custom_id=cid, + user=target, + message=msg, + client=Bot, + ) + with patch("killua.bot.randint", return_value=100): + await self.cog.on_interaction(ix) + + assert self.base_context.result.message.embeds, ( + self.base_context.result.message.embeds + ) + emb = _embed0_actions(self.base_context.result.message) + assert emb is not None + t = emb.title or "" + assert self.base_author.display_name in t, t + assert target.display_name in t, t + class Pat(_ActionCommand): def __init__(self): @@ -262,11 +329,6 @@ def __init__(self): super().__init__("dance") -class Neko(_NoArgsCommand): - def __init__(self): - super().__init__("neko") - - class Smile(_NoArgsCommand): def __init__(self): super().__init__("smile") @@ -277,6 +339,21 @@ def __init__(self): super().__init__("blush") -class Tail(_NoArgsCommand): +class Cry(_NoArgsCommand): + def __init__(self): + super().__init__("cry") + + +class Smug(_NoArgsCommand): + def __init__(self): + super().__init__("smug") + + +class Yawn(_NoArgsCommand): + def __init__(self): + super().__init__("yawn") + + +class Nope(_NoArgsCommand): def __init__(self): - super().__init__("tail") + super().__init__("nope") diff --git a/killua/tests/groups/api.py b/killua/tests/groups/api.py new file mode 100644 index 000000000..cb428072a --- /dev/null +++ b/killua/tests/groups/api.py @@ -0,0 +1,911 @@ +"""Mocked IPCRoutes handler tests (no ZMQ poll loop).""" + +from __future__ import annotations + +from datetime import datetime +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +from discord.ext import commands +from PIL import Image + +from ..testing import Testing, test, collect_test_classes +from ..types import Bot +from ...cogs.api import IPCRoutes, NewsMessage +from ...static.constants import DB, NEWS_CHANNEL, POST_CHANNEL, UPDATE_CHANNEL +from ...utils.classes import User + + +class TestingApi(Testing): + _ipc: IPCRoutes | None = None + + def __init__(self) -> None: + from discord.ext.commands.view import StringView + + from ..types import ( + Bot as TestBot, + Context, + DiscordGuild, + DiscordMember, + Message, + TestResult, + TextChannel, + ) + + if TestingApi._ipc is None: + TestingApi._ipc = IPCRoutes(TestBot) + self.base_guild = DiscordGuild() + self.base_channel = TextChannel(guild=self.base_guild) + self.base_author = DiscordMember() + self.base_message = Message( + author=self.base_author, channel=self.base_channel + ) + self.base_context = Context( + message=self.base_message, bot=TestBot, view=StringView("testing") + ) + self.result = TestResult() + self.cog = TestingApi._ipc + + @property + def all_tests(self): + return collect_test_classes(self.__class__) + + +class _ApiTests(TestingApi): + pass + + +class NewsMessageTests(_ApiTests): + @test + async def make_view_update_type(self) -> None: + DB.news.db["news"] = [ + { + "_id": "prev", + "type": "update", + "published": True, + "version": "1.0.0", + "timestamp": datetime(2020, 1, 1), + } + ] + msg = NewsMessage.from_data( + Bot, + { + "_id": "n2", + "title": "T", + "content": "Body", + "author": str(self.base_author.id), + "type": "update", + "version": "2.0.0", + "timestamp": datetime(2021, 1, 1), + "images": [], + "links": {"docs": "https://example.com"}, + }, + ) + view, files = await msg._make_view(include_ping=False) + assert view is not None + + @test + async def invalid_news_type_ping_raises(self) -> None: + msg = NewsMessage.from_data( + Bot, + { + "_id": "x", + "title": "t", + "content": "c", + "author": "1", + "type": "invalid", + "timestamp": datetime.now(), + }, + ) + try: + _ = msg.relevant_ping + assert False + except ValueError: + pass + + @test + async def from_data_round_trip(self) -> None: + data = { + "_id": "n1", + "title": "T", + "content": "C", + "author": 1, + "type": "news", + "version": "1.0", + "images": [], + "links": {}, + "notify_users": [], + "timestamp": datetime.now(), + } + msg = NewsMessage.from_data(Bot, data) + assert msg.id == "n1" + assert msg.title == "T" + assert msg._type == "news" + + @test + async def from_id_missing_raises(self) -> None: + DB.news.db["news"] = [] + try: + await NewsMessage.from_id(Bot, "missing") + assert False + except ValueError: + pass + + @test + async def from_id_returns_instance(self) -> None: + DB.news.db["news"] = [ + { + "_id": "nid", + "title": "Hi", + "content": "Body", + "author": self.base_author.id, + "type": "update", + "timestamp": datetime.now(), + } + ] + msg = await NewsMessage.from_id(Bot, "nid") + assert isinstance(msg, NewsMessage) + assert msg.title == "Hi" + + @test + async def relevant_channel_ids(self) -> None: + assert NewsMessage.relevant_channel_id("news") == NEWS_CHANNEL + assert NewsMessage.relevant_channel_id("post") == POST_CHANNEL + assert NewsMessage.relevant_channel_id("other") == UPDATE_CHANNEL + + @test + async def relevant_ping_roles(self) -> None: + msg = NewsMessage.from_data( + Bot, + { + "_id": "x", + "title": "t", + "content": "c", + "author": "1", + "type": "post", + "timestamp": datetime.now(), + }, + ) + assert msg.relevant_ping is not None + + +class IpcHandlerTests(_ApiTests): + @test + async def heartbeat(self) -> None: + ipc = self.cog + assert await ipc.heartbeat({}) == {"status": "ok"} + + @test + async def jsonify_helpers(self) -> None: + ipc = self.cog + out = ipc.jsonify( + { + "when": datetime(2020, 1, 1), + "id": 1234567890123456789, + "nested": [{"t": datetime(2021, 2, 2)}], + } + ) + assert isinstance(out["when"], str) + assert isinstance(out["id"], str) + + @test + async def make_grey_and_crop(self) -> None: + ipc = self.cog + img = Image.new("RGBA", (32, 32), (255, 0, 0, 128)) + grey = ipc.make_grey(img) + assert grey.mode == "RGBA" + circ = ipc.crop_to_circle(img.copy()) + assert circ.size == (32, 32) + + @test + async def download_image(self) -> None: + ipc = self.cog + buf = BytesIO() + Image.new("RGB", (8, 8), "blue").save(buf, format="PNG") + buf.seek(0) + + class Resp: + status = 200 + + async def read(self): + return buf.getvalue() + + ipc.client.session.get = AsyncMock(return_value=Resp()) + im = await ipc.download("http://example.test/x.png") + assert im.size == (8, 8) + + @test + async def stats(self) -> None: + ipc = self.cog + info = MagicMock() + info.approximate_user_install_count = 5 + ipc.client.application_info = AsyncMock(return_value=info) + DB.teams.db["teams"] = [{"id": 1}, {"id": 2}, {"id": 3}] + res = await ipc.stats({}) + assert res["guilds"] == len(ipc.client.guilds) + assert res["registered_users"] == 3 + + @test + async def commands_format(self) -> None: + ipc = self.cog + with patch.object( + ipc.client, "get_raw_formatted_commands", return_value=[] + ): + assert await ipc.commands({}) == {} + + @test + async def user_get_basic_details(self) -> None: + ipc = self.cog + uid = self.base_author.id + res = await ipc.user_get_basic_details({"user_id": uid}) + assert "display_name" in res + + @test + async def user_info_with_email(self) -> None: + ipc = self.cog + uid = self.base_author.id + await User.new(uid) + res = await ipc.user_info( + {"user_id": uid, "email": "a@b.c", "from_admin": True} + ) + assert res["email"] == "a@b.c" + + @test + async def discord_application_auth(self) -> None: + ipc = self.cog + uid = self.base_author.id + await ipc.discord_application_authorized({"user": {"id": uid}}) + user = await User.new(uid) + assert user.id == uid + + @test + async def discord_application_deauth(self) -> None: + ipc = self.cog + uid = self.base_author.id + await User.new(uid) + await ipc.discord_application_deauthorized({"user": {"id": uid}}) + + @test + async def news_save_published(self) -> None: + ipc = self.cog + DB.news.db["news"] = [] + with patch.object(ipc, "_send_discord_message", AsyncMock(return_value=999)): + res = await ipc.news_save( + { + "title": "Pub", + "content": "C", + "type": "news", + "author": str(self.base_author.id), + "published": True, + "notify_users": [], + } + ) + assert res["message_id"] == 999 + + @test + async def news_save_draft(self) -> None: + ipc = self.cog + DB.news.db["news"] = [] + res = await ipc.news_save( + { + "title": "T", + "content": "C", + "type": "news", + "author": str(self.base_author.id), + "published": False, + "notify_users": [], + } + ) + assert res["news_id"] + assert res["message_id"] is None + + @test + async def news_delete_missing_raises(self) -> None: + ipc = self.cog + DB.news.db["news"] = [] + try: + await ipc.news_delete({"news_id": "missing"}) + assert False, "expected ValueError" + except ValueError: + pass + + @test + async def news_delete_with_message(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + {"_id": "d2", "type": "news", "messageId": 12345, "published": True} + ] + with patch.object(ipc, "_delete_discord_message", AsyncMock()): + res = await ipc.news_delete({"news_id": "d2"}) + assert res["status"] == "deleted" + + @test + async def get_discord_user(self) -> None: + ipc = self.cog + uid = self.base_author.id + user = MagicMock() + user.display_name = "Tester" + user.name = "tester" + user.avatar.url = "https://example.com/a.png" + user.created_at = datetime.now() + ipc.client.get_user = MagicMock(return_value=user) + res = await ipc.get_discord_user({"user": uid}) + assert res["name"] == "Tester" + + @test + async def top_empty(self) -> None: + ipc = self.cog + DB.teams.db["teams"] = [] + assert await ipc.top({}) == [] + + @test + async def vote_handler(self) -> None: + ipc = self.cog + uid = self.base_author.id + await User.new(uid) + usr = MagicMock() + usr.send = AsyncMock() + ipc.client.get_user = MagicMock(return_value=usr) + ipc.client.fetch_user = AsyncMock(return_value=usr) + with patch.object(ipc, "streak_image", AsyncMock(return_value=BytesIO(b"x"))): + await ipc.vote({"user": uid, "isWeekend": False}) + + @test + async def guild_editable(self) -> None: + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock( + side_effect=lambda x: self.base_guild if x == gid else None + ) + res = await ipc.guild_editable({"guild_ids": [gid, 999999]}) + assert gid in res["editable"] + + @test + async def guild_info_and_edit(self) -> None: + from ...utils.classes.guild import Guild as KilluaGuild + + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock(return_value=self.base_guild) + await KilluaGuild.new(gid) + res = await ipc.guild_info({"guild_id": gid}) + assert "prefix" in res + edit = await ipc.guild_edit({"guild_id": gid, "prefix": "k?"}) + assert edit["success"] is True + + @test + async def news_edit_flow(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + { + "_id": "e1", + "title": "Old", + "content": "C", + "type": "news", + "author": self.base_author.id, + "published": False, + "timestamp": datetime.now(), + } + ] + res = await ipc.news_edit( + {"news_id": "e1", "title": "New", "content": "C", "type": "news"} + ) + assert res["news_id"] == "e1" + + @test + async def register_login_first_time(self) -> None: + ipc = self.cog + uid = self.base_author.id + u = await User.new(uid) + user = MagicMock() + user.send = AsyncMock() + with patch.object(u, "register_login", AsyncMock(return_value=True)): + with patch.object(u, "add_lootbox", AsyncMock()): + with patch.object(u, "add_jenny", AsyncMock()): + await ipc._register_login(user, u) + + @test + async def user_info_requires_id(self) -> None: + ipc = self.cog + try: + await ipc.user_info({}) + assert False + except ValueError: + pass + + @test + async def commands_payload(self) -> None: + ipc = self.cog + res = await ipc.commands({}) + assert isinstance(res, dict) + + @test + async def user_get_basic_details(self) -> None: + ipc = self.cog + ipc.client.get_user = MagicMock(return_value=self.base_author) + res = await ipc.user_get_basic_details({"user_id": self.base_author.id}) + assert res["display_name"] == self.base_author.display_name + + @test + async def user_get_basic_details_fetch_fallback(self) -> None: + ipc = self.cog + user = MagicMock() + user.display_name = "Fetched" + user.avatar = None + ipc.client.get_user = MagicMock(return_value=None) + ipc.client.fetch_user = AsyncMock(return_value=user) + res = await ipc.user_get_basic_details({"user_id": 999888777}) + assert res["display_name"] == "Fetched" + assert res["avatar_url"] is None + + @test + async def user_get_basic_details_invalid_id(self) -> None: + ipc = self.cog + try: + await ipc.user_get_basic_details({"user_id": "not-a-number"}) + assert False + except ValueError: + pass + + @test + async def news_save_publishes_message(self) -> None: + ipc = self.cog + DB.news.db["news"] = [] + with patch.object(ipc, "_send_discord_message", AsyncMock(return_value=4242)): + res = await ipc.news_save( + { + "title": "Launch", + "content": "Body", + "type": "news", + "author": self.base_author.id, + "published": True, + "notify_users": [], + } + ) + assert res["message_id"] == 4242 + + @test + async def guild_tag_create_success(self) -> None: + from ...cogs.tags import Tags + from ...utils.classes.guild import Guild as KilluaGuild + + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock(return_value=self.base_guild) + await KilluaGuild.new(gid) + with patch.object( + Tags, "initial_new_tag_validation", AsyncMock(return_value=None) + ): + with patch.object(Tags, "_validate_tag_details", return_value=None): + res = await ipc.guild_tag_create( + { + "guild_id": gid, + "name": "tag-a", + "content": "hello", + "user_id": self.base_author.id, + } + ) + assert res["success"] is True + + @test + async def guild_tag_delete_success(self) -> None: + from ...utils.classes.guild import Guild as KilluaGuild + + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock(return_value=self.base_guild) + guild = await KilluaGuild.new(gid) + guild.tags = [ + { + "name": "remove-me", + "content": "bye", + "owner": self.base_author.id, + "uses": 0, + "created_at": datetime.now(), + } + ] + await guild._update_val("tags", guild.tags) + KilluaGuild.cache.pop(gid, None) + res = await ipc.guild_tag_delete({"guild_id": gid, "name": "remove-me"}) + assert res["success"] is True + + @test + async def guild_tag_edit_success(self) -> None: + from ...cogs.tags import Tags + from ...utils.classes.guild import Guild as KilluaGuild + + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock(return_value=self.base_guild) + guild = await KilluaGuild.new(gid) + guild.tags = [ + { + "name": "edit-me", + "content": "old", + "owner": self.base_author.id, + "uses": 0, + "created_at": datetime.now(), + } + ] + await guild._update_val("tags", guild.tags) + KilluaGuild.cache.pop(gid, None) + with patch.object(Tags, "_validate_tag_details", return_value=None): + res = await ipc.guild_tag_edit( + { + "guild_id": gid, + "name": "edit-me", + "content": "new text", + } + ) + assert res["success"] is True + + @test + async def guild_tag_edit_rename(self) -> None: + from ...cogs.tags import Tags + from ...utils.classes.guild import Guild as KilluaGuild + + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock(return_value=self.base_guild) + guild = await KilluaGuild.new(gid) + guild.tags = [ + { + "name": "rename-me", + "content": "old", + "owner": self.base_author.id, + "uses": 0, + "created_at": datetime.now(), + } + ] + await guild._update_val("tags", guild.tags) + KilluaGuild.cache.pop(gid, None) + with patch.object(Tags, "_validate_tag_details", return_value=None): + res = await ipc.guild_tag_edit( + { + "guild_id": gid, + "name": "rename-me", + "new_name": "renamed", + "content": "new body", + "new_owner": self.base_author.id + 1, + } + ) + assert res["success"] is True + + @test + async def news_edit_publish(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + { + "_id": "pub1", + "title": "Draft", + "content": "C", + "type": "news", + "author": self.base_author.id, + "published": False, + "timestamp": datetime.now(), + } + ] + with patch.object(ipc, "_send_discord_message", AsyncMock(return_value=5555)): + res = await ipc.news_edit( + {"news_id": "pub1", "published": True, "title": "Draft", "content": "C", "type": "news"} + ) + assert res["message_id"] == 5555 + + @test + async def news_edit_unpublish(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + { + "_id": "unpub1", + "title": "Live", + "content": "C", + "type": "news", + "author": self.base_author.id, + "published": True, + "messageId": 7777, + "timestamp": datetime.now(), + } + ] + with patch.object(ipc, "_delete_discord_message", AsyncMock()): + res = await ipc.news_edit( + {"news_id": "unpub1", "published": False, "title": "Live", "content": "C", "type": "news"} + ) + assert res["message_id"] is None + + @test + async def guild_tag_delete_not_found(self) -> None: + from ...utils.classes.guild import Guild as KilluaGuild + + ipc = self.cog + gid = self.base_guild.id + ipc.client.get_guild = MagicMock(return_value=self.base_guild) + await KilluaGuild.new(gid) + res = await ipc.guild_tag_delete({"guild_id": gid, "name": "missing-tag"}) + assert res["success"] is False + + @test + async def news_edit_updates_published_message(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + { + "_id": "live1", + "title": "Live", + "content": "C", + "type": "news", + "author": self.base_author.id, + "published": True, + "messageId": 8888, + "timestamp": datetime.now(), + } + ] + with patch.object(ipc, "_edit_discord_message", AsyncMock()): + res = await ipc.news_edit( + {"news_id": "live1", "title": "Updated", "content": "C", "type": "news"} + ) + assert res["news_id"] == "live1" + + @test + async def delete_discord_message(self) -> None: + from ...static.constants import NEWS_CHANNEL + + ipc = self.cog + channel = MagicMock() + message = MagicMock() + message.delete = AsyncMock() + channel.fetch_message = AsyncMock(return_value=message) + ipc.client.get_channel = MagicMock(return_value=channel) + await ipc._delete_discord_message("news", "12345") + message.delete.assert_awaited_once() + ipc.client.get_channel.assert_called_with(NEWS_CHANNEL) + + +from ...utils.topgg import ( + TOPGG_ANNOUNCEMENTS_URL, + TOPGG_METRICS_URL, + post_announcement, + post_metrics, +) + + +class _MockTopggResponse: + def __init__(self, status: int = 200, body: str = "") -> None: + self.status = status + self._body = body + + async def text(self) -> str: + return self._body + + async def __aenter__(self): + return self + + async def __aexit__(self, *args) -> None: + return None + + +def _mock_topgg_session(session, *, status: int = 200, body: str = "") -> MagicMock: + mock_request = MagicMock(return_value=_MockTopggResponse(status, body)) + session.request = mock_request + return mock_request + + +class TopggAnnouncementTests(_ApiTests): + def _news_item(self, *, news_type="news", title="Launch", content="Body text here"): + return { + "_id": "topgg1", + "title": title, + "content": content, + "type": news_type, + "author": self.base_author.id, + "timestamp": datetime.now(), + "notify_users": [], + } + + @test + async def normalize_token_strips_quotes_and_bearer_prefix(self) -> None: + from killua.utils.topgg import _normalize_token + + assert _normalize_token('"abc"') == "abc" + assert _normalize_token("'abc'") == "abc" + assert _normalize_token("Bearer xyz") == "xyz" + assert _normalize_token(" token ") == "token" + + @test + async def publish_posts_announcement(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session) + with patch("killua.utils.topgg._token", return_value="test-token"): + await ipc._publish_topgg_announcement(self._news_item()) + mock_request.assert_called_once() + method, url = mock_request.call_args.args[:2] + assert method == "POST" + assert url == TOPGG_ANNOUNCEMENTS_URL + assert mock_request.call_args.kwargs["json"] == { + "title": "News: Launch", + "content": "Body text here", + } + assert ( + mock_request.call_args.kwargs["headers"]["Authorization"] + == "Bearer test-token" + ) + + @test + async def publish_prefixes_type_in_title(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session) + with patch("killua.utils.topgg._token", return_value="test-token"): + await ipc._publish_topgg_announcement( + self._news_item(news_type="update", title="v2.0") + ) + assert mock_request.call_args.kwargs["json"]["title"] == "Update: v2.0" + + @test + async def publish_truncates_long_title(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session) + long_title = "x" * 200 + with patch("killua.utils.topgg._token", return_value="test-token"): + await ipc._publish_topgg_announcement( + self._news_item(title=long_title, content="Body text here") + ) + title = mock_request.call_args.kwargs["json"]["title"] + assert len(title) == 100 + assert title.endswith("...") + assert title.startswith("News: ") + + @test + async def publish_truncates_long_content_with_link(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session) + long_content = "word " * 500 + with patch("killua.utils.topgg._token", return_value="test-token"): + await ipc._publish_topgg_announcement( + self._news_item(title="Launch", content=long_content) + ) + content = mock_request.call_args.kwargs["json"]["content"] + assert len(content) <= 2000 + assert content.endswith("Read the rest at https://killua.dev/news/topgg1") + assert long_content not in content + + @test + async def format_topgg_title_and_content_helpers(self) -> None: + ipc = self.cog + assert IPCRoutes._format_topgg_title("news", "Hi") == "News: Hi" + assert len(IPCRoutes._format_topgg_title("news", "t" * 200)) == 100 + msg = NewsMessage.from_data( + ipc.client, + self._news_item(content="short"), + ) + assert IPCRoutes._format_topgg_content(msg, "short") == "short" + long_body = "a" * 2500 + formatted = IPCRoutes._format_topgg_content(msg, long_body) + assert len(formatted) == 2000 + assert formatted.endswith(f"Read the rest at {msg.url}") + + @test + async def publish_skips_without_token(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session) + with patch("killua.utils.topgg._token", return_value=None): + await ipc._publish_topgg_announcement(self._news_item()) + mock_request.assert_not_called() + + @test + async def publish_skips_in_dev(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session) + ipc.client.is_dev = True + try: + with patch("killua.utils.topgg._token", return_value="test-token"): + await ipc._publish_topgg_announcement(self._news_item()) + mock_request.assert_not_called() + finally: + ipc.client.is_dev = False + + @test + async def post_metrics_uses_v1_patch(self) -> None: + ipc = self.cog + mock_request = _mock_topgg_session(ipc.client.session, status=204) + with patch("killua.utils.topgg._token", return_value="test-token"): + ok = await post_metrics( + ipc.client.session, server_count=420, shard_count=2 + ) + assert ok is True + method, url = mock_request.call_args.args[:2] + assert method == "PATCH" + assert url == TOPGG_METRICS_URL + assert mock_request.call_args.kwargs["json"] == { + "server_count": 420, + "shard_count": 2, + } + + @test + async def post_metrics_returns_false_on_http_error(self) -> None: + ipc = self.cog + _mock_topgg_session( + ipc.client.session, + status=401, + body='{"title":"Unauthorized"}', + ) + with patch("killua.utils.topgg._token", return_value="test-token"): + ok = await post_metrics(ipc.client.session, server_count=1) + assert ok is False + + @test + async def news_save_published_posts_topgg(self) -> None: + ipc = self.cog + DB.news.db["news"] = [] + mock_request = _mock_topgg_session(ipc.client.session) + with patch("killua.utils.topgg._token", return_value="test-token"): + with patch.object(ipc, "_send_discord_message", AsyncMock(return_value=4242)): + await ipc.news_save( + { + "title": "Launch", + "content": "Published body text", + "type": "post", + "author": self.base_author.id, + "published": True, + "notify_users": [], + } + ) + mock_request.assert_called_once() + assert mock_request.call_args.kwargs["json"]["title"] == "Post: Launch" + + @test + async def news_edit_first_publish_posts_topgg(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + { + "_id": "pub-topgg", + "title": "Draft", + "content": "Going live now", + "type": "news", + "author": self.base_author.id, + "published": False, + "timestamp": datetime.now(), + "notify_users": [], + } + ] + mock_request = _mock_topgg_session(ipc.client.session) + with patch("killua.utils.topgg._token", return_value="test-token"): + with patch.object(ipc, "_send_discord_message", AsyncMock(return_value=5555)): + await ipc.news_edit( + { + "news_id": "pub-topgg", + "published": True, + "title": "Draft", + "content": "Going live now", + "type": "news", + } + ) + mock_request.assert_called_once() + assert mock_request.call_args.kwargs["json"]["title"] == "News: Draft" + + @test + async def news_edit_update_does_not_repost_topgg(self) -> None: + ipc = self.cog + DB.news.db["news"] = [ + { + "_id": "live-topgg", + "title": "Live", + "content": "Already published", + "type": "news", + "author": self.base_author.id, + "published": True, + "messageId": 8888, + "timestamp": datetime.now(), + "notify_users": [], + } + ] + mock_request = _mock_topgg_session(ipc.client.session) + with patch("killua.utils.topgg._token", return_value="test-token"): + with patch.object(ipc, "_edit_discord_message", AsyncMock()): + await ipc.news_edit( + { + "news_id": "live-topgg", + "title": "Updated title", + "content": "Already published", + "type": "news", + } + ) + mock_request.assert_not_called() diff --git a/killua/tests/groups/bot_cov.py b/killua/tests/groups/bot_cov.py new file mode 100644 index 000000000..7b92f3234 --- /dev/null +++ b/killua/tests/groups/bot_cov.py @@ -0,0 +1,108 @@ +"""BaseBot helper coverage (formatting, encrypt, api_url).""" + +from __future__ import annotations + +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock + +from PIL import Image + +from ..testing import Testing, test, collect_test_classes +from ..types import Bot +from ...cogs.help import HelpCommand + + +class TestingBotCov(Testing): + def __init__(self) -> None: + super().__init__(cog=HelpCommand) + + @property + def all_tests(self): + return collect_test_classes(self.__class__) + + +class _BotTests(TestingBotCov): + pass + + +class BotHelperTests(_BotTests): + @test + async def encrypt_round_trip(self) -> None: + enc = Bot._encrypt(12345, smallest=False) + assert isinstance(enc, str) + assert len(enc) > 0 + + @test + async def api_url_branches(self) -> None: + assert Bot.api_url(to_fetch=True).startswith("http://") + prev_local, prev_docker = Bot.force_local, Bot.run_in_docker + Bot.force_local = True + Bot.run_in_docker = False + try: + local = Bot.api_url(is_for_cards=True) + assert local.startswith("http://") and local != Bot.url + finally: + Bot.force_local = prev_local + Bot.run_in_docker = prev_docker + + @test + async def get_formatted_commands(self) -> None: + cmds = Bot.get_formatted_commands() + assert isinstance(cmds, dict) + + @test + async def get_raw_formatted_commands(self) -> None: + raw = Bot.get_raw_formatted_commands() + assert isinstance(raw, list) + + @test + async def find_user(self) -> None: + ctx = self.base_context + uid = str(self.base_author.id) + found = await Bot.find_user(ctx, uid) + assert found is not None + assert await Bot.find_user(ctx, "not-a-real-user-id") is None + + @test + async def is_user_installed(self) -> None: + ctx = self.base_context + ctx.guild = None + assert Bot.is_user_installed(ctx) in (True, False) + + @test + async def sha256_for_api(self) -> None: + token, expiry = Bot.sha256_for_api("test", 60) + assert token and expiry + + @test + async def get_lootbox_from_name(self) -> None: + from ...static.constants import LOOTBOXES + + name = next(v["name"] for v in LOOTBOXES.values() if v.get("available")) + assert Bot.get_lootbox_from_name(name) is not None + assert Bot.get_lootbox_from_name("not-a-real-box") is None + + @test + async def convert_to_timestamp(self) -> None: + ts = Bot.convert_to_timestamp(self.base_author.id) + assert " None: + cmd = Bot.get_command("daily") + if cmd: + assert Bot._get_group(cmd) is not None or Bot._get_group(cmd) is None + + @test + async def find_dominant_color(self) -> None: + class Resp: + status = 200 + + async def read(self): + buf = BytesIO() + Image.new("RGB", (4, 4), "red").save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + color = await Bot.find_dominant_color("http://example.test/x.png") + assert isinstance(color, int) diff --git a/killua/tests/groups/cards.py b/killua/tests/groups/cards.py index 51a389063..5795a0c34 100644 --- a/killua/tests/groups/cards.py +++ b/killua/tests/groups/cards.py @@ -2,20 +2,66 @@ from ...utils.classes import * from ..testing import Testing, test from ...cogs.cards import Cards -from ...static.cards import Card +from ...static.cards import Card as IndividualCard +from ...utils.classes.card import Card from ...utils.paginator import Buttons -from ...static.constants import PRICES -from killua.static.enums import SellOptions +from ...static.constants import PRICES, DEF_SPELLS, VIEW_DEF_SPELLS +import json +from pathlib import Path from random import randint from math import ceil from datetime import datetime, timedelta +from unittest.mock import patch + +from ..harnesses import embed_footer_page, press_paginator_button class TestingCards(Testing): + requires_command = True + + _cards_initialized = False def __init__(self): + if not Card.raw: + cards_file = Path(__file__).parents[3] / "cards.json" + if cards_file.exists(): + with open(cards_file) as f: + Card.raw = json.load(f) + super().__init__(cog=Cards) + self._mock_cog_externals() + + def _mock_cog_externals(self): + self.cog.reward_cache = { + "item": Card.find( + lambda c: c["type"] == "normal" and c["rank"] in ["A", "B", "C"] + ), + "spell": Card.find( + lambda c: c["type"] == "spell" and c["rank"] in ["B", "C"] + ), + "monster": { + "E": Card.find( + lambda c: c["type"] == "monster" and c["rank"] in ["E", "G", "H"] + ), + "D": Card.find( + lambda c: c["type"] == "monster" and c["rank"] in ["D", "E", "F"] + ), + "C": Card.find( + lambda c: c["type"] == "monster" and c["rank"] in ["C", "D", "E"] + ), + }, + } + + if not TestingCards._cards_initialized: + from PIL import Image as PILImage + from ...utils.classes.book import Book as BookClass + + async def _mock_create_image(self, data, restricted_slots, page): + return PILImage.new("RGBA", (620 * 2, 400 * 2), (255, 255, 255, 255)) + + BookClass.create_image = _mock_create_image + TestingCards._cards_initialized = True class Book(TestingCards): @@ -25,7 +71,8 @@ def __init__(self): @test async def test_with_no_cards(self) -> None: - User(self.base_context.author.id).nuke_cards() + user = await User.new(self.base_context.author.id) + await user.nuke_cards() await self.cog.book(self.cog, self.base_context) @@ -35,10 +82,8 @@ async def test_with_no_cards(self) -> None: @test async def invalid_page_chosen(self) -> None: - user = User(self.base_author.id) - user.add_card( - randint(1, 99) - ) # To prevent no cards error as that check is before this one + user = await User.new(self.base_author.id) + await user.add_card(randint(1, 99)) await self.cog.book(self.cog, self.base_context, page=8) assert ( @@ -46,19 +91,26 @@ async def invalid_page_chosen(self) -> None: == f"Please choose a page number between 1 and {6+ceil(len(user.fs_cards)/18)}" ), self.base_context.result.message.content + @test + async def page_below_one(self) -> None: + user = await User.new(self.base_author.id) + await user.add_card(randint(1, 99)) + await self.cog.book(self.cog, self.base_context, page=0) + + assert ( + self.base_context.result.message.content + == f"Please choose a page number between 1 and {6+ceil(len(user.fs_cards)/18)}" + ), self.base_context.result.message.content + @test async def responds_with_valid_paginator(self) -> None: - user = User(self.base_author.id) - user.add_card( - randint(1, 99) - ) # To prevent no cards error as that check is before this one - self.base_context.timeout_view = ( - True # Make the view instantly time out to prevent long wait - ) + user = await User.new(self.base_author.id) + await user.add_card(randint(1, 99)) + self.base_context.timeout_view = True await self.cog.book(self.cog, self.base_context) - assert isinstance(self.base_context.result.message.view, Buttons), isinstance( - self.base_context.result.message.view, Buttons + assert isinstance(self.base_context.current_view, Buttons), type( + self.base_context.current_view ) @@ -66,7 +118,6 @@ class Sell(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def no_arguments(self) -> None: @@ -78,9 +129,37 @@ async def no_arguments(self) -> None: == "You need to specify what exactly to sell" ), self.base_context.result.message.content + @test + async def invalid_card_id(self) -> None: + user = await User.new(self.base_author.id) + await user.add_card(1) + await self.command(self.cog, self.base_context, "99999") + + assert ( + self.base_context.result.message.content + == "A card with the id `99999` does not exist" + ), self.base_context.result.message.content + + @test + async def cancel_sell(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + card = randint(1, 99) + await user.add_card(card) + + await self.command(self.cog, self.base_context, card=str(card)) + + assert ( + self.base_context.result.message.content == "Successfully canceled!" + ), self.base_context.result.message.content + assert ( + user.count_card(card, including_fakes=False) == 1 + ), user.count_card(card, including_fakes=False) + @test async def sell_without_any_cards(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") await self.command(self.cog, self.base_context, "1") assert ( @@ -89,9 +168,9 @@ async def sell_without_any_cards(self) -> None: @test async def selling_a_card_not_in_possession(self) -> None: - self.user.add_card( - 6 - ) # Add a card to avoid "You don't have any cards yet!" error + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(6) await self.command(self.cog, self.base_context, "5") @@ -103,11 +182,11 @@ async def selling_a_card_not_in_possession(self) -> None: @test async def sell_single_valid_card(self) -> None: self.base_context.timeout_view = False - self.base_context.respond_to_view = Testing.press_confirm card = randint(1, 99) - self.user.nuke_cards("all") - self.user.add_card(card) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(card) await self.command(self.cog, self.base_context, card=str(card)) assert ( @@ -115,13 +194,15 @@ async def sell_single_valid_card(self) -> None: == f"Successfully sold 1 copy of card {card} for {int(PRICES[Card(card).rank] * 0.1)} Jenny!" ), self.base_context.result.message.content assert ( - self.user.count_card(card, including_fakes=False) == 0 - ), self.user.count_card(card, including_fakes=False) + user.count_card(card, including_fakes=False) == 0 + ), user.count_card(card, including_fakes=False) @test async def sell_single_fake(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") card = randint(1, 99) - self.user.add_card(card, fake=True) + await user.add_card(card, fake=True) await self.command(self.cog, self.base_context, card=str(card)) assert ( @@ -131,8 +212,10 @@ async def sell_single_fake(self) -> None: @test async def sell_more_cards_than_in_posession(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") card = randint(1, 99) - self.user.add_card(card) + await user.add_card(card) await self.command(self.cog, self.base_context, card=str(card), amount=2) assert ( @@ -143,11 +226,11 @@ async def sell_more_cards_than_in_posession(self) -> None: @test async def selling_multiple_cards(self) -> None: self.base_context.timeout_view = False - self.base_context.respond_to_view = Testing.press_confirm + user = await User.new(self.base_author.id) card = randint(1, 99) for _ in range(2): - self.user.add_card(card) + await user.add_card(card) await self.command(self.cog, self.base_context, card=str(card), amount=2) assert ( @@ -155,14 +238,16 @@ async def selling_multiple_cards(self) -> None: == f"Successfully sold 2 copies of card {card} for {int(PRICES[Card(card).rank] * 0.2)} Jenny!" ), self.base_context.result.message.content assert ( - self.user.count_card(card, including_fakes=False) == 0 - ), self.user.count_card(card, including_fakes=False) + user.count_card(card, including_fakes=False) == 0 + ), user.count_card(card, including_fakes=False) @test async def selling_multiple_cards_with_fake(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") card = randint(1, 99) - self.user.add_card(card) - self.user.add_card(card, fake=True) + await user.add_card(card) + await user.add_card(card, fake=True) await self.command(self.cog, self.base_context, card=str(card), amount=2) @@ -171,15 +256,15 @@ async def selling_multiple_cards_with_fake(self) -> None: == "Seems you don't own enough copies of this card. You own 1 copy of this card" ), self.base_context.result.message.content - self.user.remove_card(card, remove_fake=True) and self.user.remove_card(card) + await user.remove_card(card, remove_fake=True) + await user.remove_card(card) @test async def sell_all_of_category_when_owning_none(self) -> None: - self.user.add_card( - 1 - ) # So there won't be the generic "you have no cards" error message + user = await User.new(self.base_author.id) + await user.add_card(1) self.base_context.respond_to_view = self.press_confirm - await self.command(self.cog, self.base_context, type=SellOptions.monsters) + await self.command(self.cog, self.base_context, sell_opt="monsters") assert ( self.base_context.result.message.content @@ -188,42 +273,46 @@ async def sell_all_of_category_when_owning_none(self) -> None: @test async def sell_all_of_category(self) -> None: - self.user.add_card(572) - self.user.add_card(697) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(572) + await user.add_card(697) self.base_context.respond_to_view = self.press_confirm - await self.command(self.cog, self.base_context, type=SellOptions.monsters) + await self.command(self.cog, self.base_context, sell_opt="monsters") assert ( self.base_context.result.message.content == f"You sold all your monsters for {int((PRICES[Card(572).rank] + PRICES[Card(697).rank]) * 0.1)} Jenny!" ), self.base_context.result.message.content assert ( - self.user.count_card(572, including_fakes=False) == 0 - and self.user.count_card(697, including_fakes=False) == 0 + user.count_card(572, including_fakes=False) == 0 + and user.count_card(697, including_fakes=False) == 0 ), ( - self.user.count_card(572, including_fakes=False) == 0 - and self.user.count_card(697, including_fakes=False) == 0 + user.count_card(572, including_fakes=False) == 0 + and user.count_card(697, including_fakes=False) == 0 ) @test async def sell_all_of_category_with_fake(self) -> None: - self.user.add_card(572) - self.user.add_card(697, fake=True) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(572) + await user.add_card(697, fake=True) self.base_context.respond_to_view = self.press_confirm - await self.command(self.cog, self.base_context, type=SellOptions.monsters) + await self.command(self.cog, self.base_context, sell_opt="monsters") assert ( self.base_context.result.message.content == f"You sold all your monsters for {int(PRICES[Card(572).rank] * 0.1)} Jenny!" ), self.base_context.result.message.content assert ( - self.user.count_card(572, including_fakes=False) == 0 - and self.user.count_card(697, including_fakes=True) == 1 + user.count_card(572, including_fakes=False) == 0 + and user.count_card(697, including_fakes=True) == 1 ), ( - self.user.count_card(572, including_fakes=False) == 0 - and self.user.count_card(697, including_fakes=True) == 1 + user.count_card(572, including_fakes=False) == 0 + and user.count_card(697, including_fakes=True) == 1 ) @@ -231,11 +320,11 @@ class Swap(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def swap_when_none_owned(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") await self.command(self.cog, self.base_context, card=str(randint(1, 99))) assert ( @@ -262,8 +351,9 @@ async def swap_card_0(self) -> None: @test async def swap_non_owned_card(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) await self.command(self.cog, self.base_context, card="2") @@ -274,8 +364,9 @@ async def swap_non_owned_card(self) -> None: @test async def swap_single_owned_card(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) await self.command(self.cog, self.base_context, card="1") @@ -286,10 +377,11 @@ async def swap_single_owned_card(self) -> None: @test async def swap_two_non_fakes(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") - self.user.add_card(1) - self.user.add_card(1) + await user.add_card(1) + await user.add_card(1) await self.command(self.cog, self.base_context, card="1") @@ -300,10 +392,11 @@ async def swap_two_non_fakes(self) -> None: @test async def correct_usage(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") - self.user.add_card(1, fake=True) - self.user.add_card(1) + await user.add_card(1, fake=True) + await user.add_card(1) await self.command(self.cog, self.base_context, card="1") @@ -311,19 +404,19 @@ async def correct_usage(self) -> None: self.base_context.result.message.content == f"Successfully swapped out card {Card('1').name}" ), self.base_context.result.message.content - assert not self.user.rs_cards[0][1]["fake"], self.user.rs_cards[0][1]["fake"] - assert self.user.fs_cards[0][1]["fake"], self.user.fs_cards[0][1]["fake"] + assert not user.rs_cards[0][1]["fake"], user.rs_cards[0][1]["fake"] + assert user.fs_cards[0][1]["fake"], user.fs_cards[0][1]["fake"] class Hunt(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def hunt_time_when_not_hunting(self) -> None: - self.user.nuke_cards("effects") - await self.command(self.cog, self.base_context, option=HuntOptions.time) + user = await User.new(self.base_author.id) + await user.nuke_cards("effects") + await self.command(self.cog, self.base_context, option="time") assert ( self.base_context.result.message.content == "You are not on a hunt yet!" @@ -331,12 +424,13 @@ async def hunt_time_when_not_hunting(self) -> None: @test async def hunt_time_when_hunting(self) -> None: - self.user.nuke_cards("effects") + user = await User.new(self.base_author.id) + await user.nuke_cards("effects") started_at = random_date() - self.user.add_effect("hunting", started_at) + await user.add_effect("hunting", started_at) - await self.command(self.cog, self.base_context, option=HuntOptions.time) + await self.command(self.cog, self.base_context, option="time") assert ( self.base_context.result.message.content @@ -345,22 +439,24 @@ async def hunt_time_when_hunting(self) -> None: @test async def hunt_end_when_not_hunting(self) -> None: - self.user.nuke_cards("effects") - await self.command(self.cog, self.base_context, option=HuntOptions.end) + user = await User.new(self.base_author.id) + await user.nuke_cards("effects") + await self.command(self.cog, self.base_context, option="end") assert ( self.base_context.result.message.content - == f"You aren't on a hunt yet! Start one with `k!hunt`" + == "You aren't on a hunt yet! Start one with `/cards hunt`" ), self.base_context.result.message.content @test async def end_hunt_below_12h(self) -> None: - self.user.nuke_cards("effects") + user = await User.new(self.base_author.id) + await user.nuke_cards("effects") started_at = datetime.now() - timedelta(minutes=10) - self.user.add_effect("hunting", started_at) + await user.add_effect("hunting", started_at) - await self.command(self.cog, self.base_context, option=HuntOptions.end) + await self.command(self.cog, self.base_context, option="end") assert ( self.base_context.result.message.content @@ -369,12 +465,13 @@ async def end_hunt_below_12h(self) -> None: @test async def end_hunt_correctly(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") started_at = datetime.now() - timedelta(hours=20) - self.user.add_effect("hunting", started_at) + await user.add_effect("hunting", started_at) - await self.command(self.cog, self.base_context, option=HuntOptions.end) + await self.command(self.cog, self.base_context, option="end") assert ( self.base_context.result.message.embeds @@ -385,43 +482,44 @@ async def end_hunt_correctly(self) -> None: assert self.base_context.result.message.embeds[0].description.startswith( f"You've started hunting . You brought back the following items from your hunt: \n\n" ), self.base_context.result.message.embeds[0].description - assert not self.user.has_effect("hunting")[0], self.user.has_effect("hunting") - assert len(self.user.all_cards) > 0, self.user.all_cards + assert not user.has_effect("hunting")[0], user.has_effect("hunting") + assert len(user.all_cards) > 0, user.all_cards @test async def start_hunting_when_on_hunt(self) -> None: - self.user.nuke_cards("effects") + user = await User.new(self.base_author.id) + await user.nuke_cards("effects") started_at = random_date() - self.user.add_effect("hunting", started_at) + await user.add_effect("hunting", started_at) - await self.command(self.cog, self.base_context, option=HuntOptions.start) + await self.command(self.cog, self.base_context, option="start") assert ( self.base_context.result.message.content - == f"You are already on a hunt! Get the results with `k!hunt end`" + == "You are already on a hunt! Get the results with `/cards hunt end`" ), self.base_context.result.message.content @test async def start_hunting_correctly(self) -> None: - self.user.nuke_cards("effects") + user = await User.new(self.base_author.id) + await user.nuke_cards("effects") - await self.command(self.cog, self.base_context, option=HuntOptions.start) + await self.command(self.cog, self.base_context, option="start") assert ( self.base_context.result.message.content - == f"You went hunting! Make sure to claim your rewards at least twelve hours from now, but remember, the longer you hunt, the more you get" + == "You went hunting! Make sure to claim your rewards at least twelve hours from now, but remember, the longer you hunt, the more you get" ), self.base_context.result.message.content - assert self.user.has_effect("hunting")[0], self.user.has_effect("hunting")[0] - assert datetime.now() - self.user.effects["hunting"] < timedelta( + assert user.has_effect("hunting")[0], user.has_effect("hunting")[0] + assert datetime.now() - user.effects["hunting"] < timedelta( minutes=1 - ), self.user.effects["hunting"] + ), user.effects["hunting"] class Meet(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def target_is_bot(self) -> None: @@ -453,6 +551,7 @@ async def no_recent_messages(self) -> None: @test async def already_met(self) -> None: + user = await User.new(self.base_author.id) other = DiscordMember() messages = [ @@ -461,21 +560,22 @@ async def already_met(self) -> None: ] messages.extend( [Message(author=other, channel=self.base_context.channel)] - ) # Add argument to recent messages + ) self.base_context.channel.history_return = messages self.base_channel.history_return = messages - self.user.add_met_user(other.id) + await user.add_met_user(other.id) await self.command(self.cog, self.base_context, other) assert ( self.base_context.result.message.content - == f"You already have `{other}` in the list of users you met, {self.base_author.name}" + == f"You already have `{other}` in the list of users you met, {self.base_author.display_name}" ), self.base_context.result.message.content @test async def meet_correctly(self) -> None: + user = await User.new(self.base_author.id) other = DiscordMember() messages = [ @@ -484,7 +584,7 @@ async def meet_correctly(self) -> None: ] messages.extend( [Message(author=other, channel=self.base_context.channel)] - ) # Add argument to recent messages + ) self.base_context.channel.history_return = messages self.base_channel.history_return = messages @@ -494,14 +594,13 @@ async def meet_correctly(self) -> None: self.base_context.result.message.content == f"Done {self.base_author.mention}! Successfully added `{other}` to the list of people you've met" ), self.base_context.result.message.content - assert self.user.has_met(other.id), self.user.met_user + assert user.has_met(other.id), user.met_user class Discard(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def discord_non_existent_card(self) -> None: @@ -513,19 +612,21 @@ async def discord_non_existent_card(self) -> None: @test async def discard_not_in_posession_card(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") await self.command(self.cog, self.base_context, "1") assert ( self.base_context.result.message.content - == "You are not in possesion of this card!" + == "You are not in possession of this card!" ), self.base_context.result.message.content @test async def discard_card_0(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(0) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(0) await self.command(self.cog, self.base_context, "0") @@ -535,8 +636,9 @@ async def discard_card_0(self) -> None: @test async def cancel_discard(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) await self.command(self.cog, self.base_context, "1") @@ -546,8 +648,9 @@ async def cancel_discard(self) -> None: @test async def discard_correctly(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) self.base_context.respond_to_view = self.press_confirm @@ -555,16 +658,15 @@ async def discard_correctly(self) -> None: assert ( self.base_context.result.message.content - == f"Successfully thrown away card No. `1`" + == "Successfully thrown away card No. `1`" ), self.base_context.result.message.content - assert not self.user.has_any_card("1"), self.user.has_any_card("1") + assert not user.has_any_card(1), user.has_any_card(1) class Cardinfo(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def invalid_card(self) -> None: @@ -576,7 +678,8 @@ async def invalid_card(self) -> None: @test async def card_not_owned(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") await self.command(self.cog, self.base_context, "1") @@ -587,8 +690,9 @@ async def card_not_owned(self) -> None: @test async def correct_usage(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) await self.command(self.cog, self.base_context, "1") @@ -608,7 +712,6 @@ class Check(TestingCards): def __init__(self): super().__init__() - self.user = User(self.base_author.id) @test async def invalid_card(self) -> None: @@ -620,7 +723,8 @@ async def invalid_card(self) -> None: @test async def card_not_owned(self) -> None: - self.user.nuke_cards("all") + user = await User.new(self.base_author.id) + await user.nuke_cards("all") await self.command(self.cog, self.base_context, "1") @@ -631,8 +735,9 @@ async def card_not_owned(self) -> None: @test async def owned_but_not_fake(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) await self.command(self.cog, self.base_context, "1") @@ -643,9 +748,10 @@ async def owned_but_not_fake(self) -> None: @test async def restricted_slots_fake(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1, fake=True) - self.user.add_card(1) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1, fake=True) + await user.add_card(1) await self.command(self.cog, self.base_context, "1") @@ -656,9 +762,10 @@ async def restricted_slots_fake(self) -> None: @test async def free_slots_fake(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1) - self.user.add_card(1, fake=True) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1) + await user.add_card(1, fake=True) await self.command(self.cog, self.base_context, "1") @@ -669,10 +776,11 @@ async def free_slots_fake(self) -> None: @test async def restricted_slots_and_free_slots_fake(self) -> None: - self.user.nuke_cards("all") - self.user.add_card(1, fake=True) - self.user.add_card(1, fake=True) - self.user.add_card(1, fake=True) + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(1, fake=True) + await user.add_card(1, fake=True) + await user.add_card(1, fake=True) await self.command(self.cog, self.base_context, "1") @@ -682,79 +790,109 @@ async def restricted_slots_and_free_slots_fake(self) -> None: ), self.base_context.result.message.content -# class Use(TestingCards): +class TestingUseSpell(TestingCards): + """Base for per-spell ``use`` integration tests.""" -# def __init__(self): -# super().__init__() -# self.user = User(self.base_author.id) + command_name = "use" -# async def test_command(self) -> None: -# """Runs all tests of a command""" + def __init__(self): + super().__init__() -# for method in test.tests(self): -# await method(self) + async def _test_prefix(bot, message): + return ("!", "!", "killua ") -# for subclass in self.__class__.__subclasses__(): -# sub = subclass() -# for method in test.tests(sub): -# await method(sub) + self.base_context.bot.command_prefix = _test_prefix -# @test -# async def invalid_card(self) -> None: -# await self.command(self.cog, self.base_context, "invalid", "irrelevant") -# assert self.base_context.result.message.content == "Invalid card id", self.base_context.result.message.content +class Use(TestingUseSpell): -# @test -# async def not_in_posession(self) -> None: -# self.user.nuke_cards("all") -# self.user.add_card(1) + def __init__(self): + super().__init__() -# await self.command(self.cog, self.base_context, "1", "irrelevant") + @test + async def invalid_card(self) -> None: + await self.command(self.cog, self.base_context, item="invalid") -# assert self.base_context.result.message.content == "You are not in possesion of this card!", self.base_context.result.message.content + assert ( + self.base_context.result.message.content == "Invalid card id" + ), self.base_context.result.message.content -# @test -# async def non_spell(self) -> None: -# self.user.nuke_cards("all") -# self.user.add_card(1) + @test + async def not_in_possession(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") -# await self.command(self.cog, self.base_context, "1", "irrelevant") + await self.command(self.cog, self.base_context, item="1011") -# assert self.base_context.result.message.content == "You can only use spell cards!", self.base_context.result.message.content + assert ( + self.base_context.result.message.content + == "You are not in possesion of this card!" + ), self.base_context.result.message.content -# @test -# async def defense_spell(self) -> None: -# self.user.nuke_cards("all") + @test + async def non_spell_card(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(572) -# await self.command(self.cog, self.base_context, "1003", "irrelevant") + await self.command(self.cog, self.base_context, item="572") -# assert self.base_context.result.message.content == "You can only use this card in response to an attack!", self.base_context.result.message.content + assert ( + self.base_context.result.message.content + == "You can only use spell cards!" + ), self.base_context.result.message.content -# class Card1001(Use): + @test + async def defense_spell(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(DEF_SPELLS[0]) -# def __init__(self): -# super().__init__() + await self.command(self.cog, self.base_context, item=str(DEF_SPELLS[0])) -# @test -# async def has_not_met(self) -> None: -# self.user.nuke_cards("all") -# self.user.add_card(1001) -# other = DiscordMember() -# self.user.met_user = [] + assert ( + self.base_context.result.message.content + == "You can only use this card in response to an attack!" + ), self.base_context.result.message.content -# await self.command(self.cog, self.base_context, "1001", other) + @test + async def view_defense_spell(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await user.add_card(VIEW_DEF_SPELLS[0]) -# assert self.base_context.result.message.content == "You haven't met this user yet! Use `k!meet <@someone>` if they send a message in a channel to be able to use this card on them", self.base_context.result.message.content + await self.command(self.cog, self.base_context, item=str(VIEW_DEF_SPELLS[0])) -# @test -# async def no_permissions(self) -> None: -# self.user.nuke_cards("all") -# self.user.add_card(1001) -# self.base_channel._has_permissions = False -# other = DiscordMember() -# self.user.add_met_user(other) + assert ( + self.base_context.result.message.content + == "You can only use this card in response to an attack!" + ), self.base_context.result.message.content -# await self.command(self.cog, self.base_context, "1001", other) + @test + async def booklet_paginator_next_page(self) -> None: + """Path A: `use booklet` embed paginator advances with next (not `book`, which uses has_file).""" + self.base_context.timeout_view = False -# assert self.base_context.result.message.content == f"You can only attack a user in a channel they have read and write permissions to which isn't the case with {other.name}", self.base_context.result.message.content + async def _next(ctx): + await press_paginator_button( + ctx.current_view, + "next", + context=ctx, + message=ctx.result.message, + ) + ctx.current_view.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _next + try: + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context, item="booklet") + finally: + self.base_context.respond_to_view = _prev_rtv + emb = self.base_context.result.message.embeds[0] + fp = embed_footer_page(emb) + assert fp == (2, 6), fp + assert "**rank**" in (emb.description or ""), emb.description + + +from . import cards_use_spells # noqa: E402, F401 — register per-spell use tests diff --git a/killua/tests/groups/cards_use_spells.py b/killua/tests/groups/cards_use_spells.py new file mode 100644 index 000000000..cc466617c --- /dev/null +++ b/killua/tests/groups/cards_use_spells.py @@ -0,0 +1,586 @@ +"""Per-spell ``cards use`` integration tests.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +from ...static.constants import FREE_SLOTS, INDESTRUCTIBLE +from ...utils.classes import User +from ...utils.classes.card import Card +from ...utils.paginator import Buttons +from ..harnesses import ( + ATTACK_TIMEOUT_FRAGMENT, + DEFAULT_ATTACK_SPELL, + MET_ERROR_FRAGMENT, + STEAL_TARGET_CARD, + assert_content_contains, + assert_embed_title, + assert_inventory, + assert_met_error, + assert_steal_blocked_by_defense, + assert_steal_succeeded, + embed_at, + ensure_no_defense, + invoke_use, + last_content, + patch_random_choice, + reload_user, + respond_defense_with_spell, + respond_to_view, + run_attack_against_defender, + seed_channel_history, + setup_author_spell, + setup_met_view_spell, + setup_target_user, + target_member, + use_view_spell_paginator, +) +from ..testing import Testing, test +from .cards import TestingUseSpell + + +class UseSpell1001(TestingUseSpell): + @test + async def success_starts_paginator(self) -> None: + target, tid = await setup_met_view_spell(self, 1001, 50_001, fs_cards=[1011, 1010]) + await use_view_spell_paginator(self, 1001, target) + + @test + async def not_met(self) -> None: + target, _ = target_member(self, 50_002) + await setup_author_spell(self.base_author.id, 1001) + await setup_target_user(target.id, fs_cards=[1011]) + await invoke_use(self, 1001, target=target) + assert_met_error(self.base_context) + + @test + async def target_empty_fs(self) -> None: + target, tid = target_member(self, 50_003) + await setup_author_spell(self.base_author.id, 1001, met_ids=[tid]) + await setup_target_user(tid, fs_cards=[]) + await invoke_use(self, 1001, target=target) + assert_content_contains(self.base_context, "uses up") + + +class UseSpell1002(TestingUseSpell): + @test + async def success_starts_paginator(self) -> None: + target, _ = await setup_met_view_spell(self, 1002, 50_004, fs_cards=[1011]) + await use_view_spell_paginator(self, 1002, target) + + @test + async def not_met(self) -> None: + target, _ = target_member(self, 50_005) + await setup_author_spell(self.base_author.id, 1002) + await invoke_use(self, 1002, target=target) + assert_met_error(self.base_context) + + +class UseSpell1007(TestingUseSpell): + @test + async def steals_from_rs(self) -> None: + target, tid = target_member(self, 50_007) + await setup_author_spell(self.base_author.id, 1007) + tu = await setup_target_user(tid, rs_cards=[STEAL_TARGET_CARD, 51]) + ensure_no_defense(tu) + with patch_random_choice(STEAL_TARGET_CARD): + await invoke_use(self, 1007, target=target) + await assert_steal_succeeded( + self.base_context, + self.base_author.id, + tid, + STEAL_TARGET_CARD, + message_fragment="Successfully stole", + ) + + @test + async def no_rs_cards(self) -> None: + target, _ = target_member(self, 50_008) + await setup_author_spell(self.base_author.id, 1007) + await invoke_use(self, 1007, target=target) + assert_content_contains(self.base_context, "restricted slots") + + +class UseSpell1008(TestingUseSpell): + @test + async def swap_success(self) -> None: + target, tid = target_member(self, 50_009) + await setup_author_spell(self.base_author.id, 1008, extra_fs=[20]) + await setup_target_user(tid, fs_cards=[30], rs_cards=[40]) + with patch("killua.static.cards.random.choice", side_effect=[30, 20]): + await invoke_use(self, 1008, target=target) + assert_content_contains(self.base_context, "Successfully swapped") + + @test + async def author_too_few_cards(self) -> None: + target, tid = target_member(self, 50_010) + await setup_author_spell(self.base_author.id, 1008) + await setup_target_user(tid, fs_cards=[1011]) + await invoke_use(self, 1008, target=target) + assert_content_contains(self.base_context, "other than card") + + +class UseSpell1010(TestingUseSpell): + @test + async def clone_success(self) -> None: + await setup_author_spell(self.base_author.id, 1010, extra_fs=[11]) + await invoke_use(self, 1010, target=11) + assert_content_contains(self.base_context, "Successfully added another copy") + user = await reload_user(self.base_author.id) + assert user.count_card(11) >= 2 + + @test + async def not_owned(self) -> None: + await setup_author_spell(self.base_author.id, 1010) + await invoke_use(self, 1010, target=11) + assert_content_contains(self.base_context, "don't own") + + @test + async def global_max(self) -> None: + await setup_author_spell(self.base_author.id, 1010, extra_fs=[11]) + + async def _many_owners(_self): + return [1] * 10_000 + + with patch.object(Card, "owners", _many_owners): + await invoke_use(self, 1010, target=11) + assert_content_contains(self.base_context, "maximum amount") + + +class UseSpell1011(TestingUseSpell): + @test + async def copy_from_target_rs(self) -> None: + target, tid = target_member(self, 50_011) + await setup_author_spell(self.base_author.id, 1011) + await setup_target_user(tid, rs_cards=[STEAL_TARGET_CARD]) + + async def _no_owners(_self): + return [] + + with patch.object(Card, "owners", _no_owners): + await invoke_use(self, 1011, target=target) + assert_content_contains(self.base_context, f"card No. {STEAL_TARGET_CARD}") + + @test + async def target_no_rs(self) -> None: + target, _ = target_member(self, 50_012) + await setup_author_spell(self.base_author.id, 1011) + await invoke_use(self, 1011, target=target) + assert_content_contains(self.base_context, "uses up") + + +class UseSpell1015(TestingUseSpell): + @test + async def success_starts_paginator(self) -> None: + target, _ = await setup_met_view_spell(self, 1015, 50_015, fs_cards=[1011]) + await use_view_spell_paginator(self, 1015, target) + + @test + async def not_met(self) -> None: + target, _ = target_member(self, 50_016) + await setup_author_spell(self.base_author.id, 1015) + await invoke_use(self, 1015, target=target) + assert_met_error(self.base_context) + + +class UseSpell1018(TestingUseSpell): + @test + async def steals_when_history_has_victim(self) -> None: + victim, vid = target_member(self, 50_018) + await setup_author_spell(self.base_author.id, 1018) + await setup_target_user(vid, rs_cards=[STEAL_TARGET_CARD]) + with seed_channel_history(self.base_context, [victim]): + with patch_random_choice([STEAL_TARGET_CARD, {"fake": False, "clone": False}]): + await invoke_use(self, 1018) + assert_content_contains(self.base_context, str(STEAL_TARGET_CARD)) + await assert_inventory(self.base_author.id, has=[STEAL_TARGET_CARD]) + + @test + async def all_defend_or_empty(self) -> None: + await setup_author_spell(self.base_author.id, 1018) + with seed_channel_history(self.base_context, []): + await invoke_use(self, 1018) + assert_content_contains(self.base_context, "defend") + + +class UseSpell1020(TestingUseSpell): + @test + async def creates_fake(self) -> None: + await setup_author_spell(self.base_author.id, 1020) + await invoke_use(self, 1020, target=11) + assert_content_contains(self.base_context, "Created a fake of card No. 11") + + @test + async def card_id_zero(self) -> None: + await setup_author_spell(self.base_author.id, 1020) + await self.command(self.cog, self.base_context, item="1020", args=0) + content = last_content(self.base_context) + assert "between 1 and 99" in content or "less than 1" in content.lower() + + @test + async def card_id_over_99(self) -> None: + await setup_author_spell(self.base_author.id, 1020) + await invoke_use(self, 1020, target=100) + content = last_content(self.base_context).lower() + assert "between 1 and 99" in content or "invalid" in content + + +class UseSpell1021(TestingUseSpell): + @test + async def steals_specific_card(self) -> None: + target, tid = target_member(self, 50_021) + await setup_author_spell(self.base_author.id, 1021) + tu = await setup_target_user(tid, rs_cards=[STEAL_TARGET_CARD]) + ensure_no_defense(tu) + await invoke_use(self, 1021, target=target, args=STEAL_TARGET_CARD) + await assert_steal_succeeded( + self.base_context, + self.base_author.id, + tid, + STEAL_TARGET_CARD, + message_fragment="Stole card number", + ) + + @test + async def target_lacks_card(self) -> None: + target, _ = target_member(self, 50_022) + await setup_author_spell(self.base_author.id, 1021) + await invoke_use(self, 1021, target=target, args=STEAL_TARGET_CARD) + assert_content_contains(self.base_context, "doesn't have this card") + + @test + async def card_id_zero(self) -> None: + target, _ = target_member(self, 50_023) + await setup_author_spell(self.base_author.id, 1021) + await invoke_use(self, 1021, target=target, args=0) + assert_content_contains(self.base_context, "less than 1") + + +class UseSpell1024(TestingUseSpell): + @test + async def removes_fakes_and_clones(self) -> None: + target, tid = target_member(self, 50_024) + await setup_author_spell(self.base_author.id, 1024) + await setup_target_user( + tid, + fs_cards=[(1007, True, False), (1008, False, True)], + rs_cards=[(STEAL_TARGET_CARD, True, False)], + ) + await invoke_use(self, 1024, target=target) + assert_content_contains(self.base_context, "Successfully removed") + + @test + async def target_no_fakes(self) -> None: + target, tid = target_member(self, 50_025) + await setup_author_spell(self.base_author.id, 1024) + await setup_target_user(tid, fs_cards=[1011]) + await invoke_use(self, 1024, target=target) + assert_content_contains(self.base_context, "does not have any cards") + + +class UseSpell1026(TestingUseSpell): + @test + async def adds_protection_effect(self) -> None: + await setup_author_spell(self.base_author.id, 1026) + await invoke_use(self, 1026) + assert_content_contains( + self.base_context, + "automatically protected from the next 10 attacks", + ) + user = await reload_user(self.base_author.id) + assert user.effects["1026"] == 10 + + @test + async def cancel_renew_confirm(self) -> None: + user = await setup_author_spell(self.base_author.id, 1026, extra_fs=[1026]) + await user.add_effect("1026", 5) + self.base_context.timeout_view = False + with respond_to_view(self.base_context, Testing.press_cancel): + await invoke_use(self, 1026) + assert_content_contains(self.base_context, "Successfully canceled") + + @test + async def renew_confirm_success(self) -> None: + user = await setup_author_spell(self.base_author.id, 1026, extra_fs=[1026]) + await user.add_effect("1026", 5) + self.base_context.timeout_view = False + with respond_to_view(self.base_context, Testing.press_confirm): + await invoke_use(self, 1026) + assert_content_contains( + self.base_context, + "automatically protected from the next 10 attacks", + ) + + @test + async def renew_confirm_timeout(self) -> None: + user = await setup_author_spell(self.base_author.id, 1026, extra_fs=[1026]) + await user.add_effect("1026", 5) + self.base_context.timeout_view = True + await invoke_use(self, 1026) + assert_content_contains(self.base_context, "Timed out") + + +class UseSpell1028(TestingUseSpell): + @test + async def destroys_fs_card(self) -> None: + target, tid = target_member(self, 50_028) + await setup_author_spell(self.base_author.id, 1028) + tu = await setup_target_user(tid, fs_cards=[1011, 1010]) + ensure_no_defense(tu) + with patch_random_choice([1011, {"fake": False, "clone": False}]): + await invoke_use(self, 1028, target=target) + assert_content_contains(self.base_context, "destroyed card No. 1011") + await assert_inventory(tid, lacks=[1011]) + + @test + async def no_fs_cards(self) -> None: + target, _ = target_member(self, 50_029) + await setup_author_spell(self.base_author.id, 1028) + await invoke_use(self, 1028, target=target) + assert_content_contains(self.base_context, "free slots") + + +class UseSpell1029(TestingUseSpell): + @test + async def destroys_rs_card(self) -> None: + target, tid = target_member(self, 50_031) + await setup_author_spell(self.base_author.id, 1029) + tu = await setup_target_user(tid, rs_cards=[STEAL_TARGET_CARD, 51]) + ensure_no_defense(tu) + with patch_random_choice([STEAL_TARGET_CARD, {"fake": False, "clone": False}]): + await invoke_use(self, 1029, target=target) + assert_content_contains(self.base_context, f"destroyed card No. {STEAL_TARGET_CARD}") + await assert_inventory(tid, lacks=[STEAL_TARGET_CARD]) + + @test + async def no_rs_cards(self) -> None: + target, _ = target_member(self, 50_032) + await setup_author_spell(self.base_author.id, 1029) + await invoke_use(self, 1029, target=target) + assert_content_contains(self.base_context, "restricted slots") + + +class UseSpell1031(TestingUseSpell): + @test + async def sends_analysis_embed(self) -> None: + await setup_author_spell(self.base_author.id, 1031) + + async def _no_owners(_self): + return [] + + with patch.object(Card, "owners", _no_owners): + await invoke_use(self, 1031, target=11) + assert_embed_title(embed_at(self.base_context), "Info about card 11") + + @test + async def invalid_card(self) -> None: + await setup_author_spell(self.base_author.id, 1031) + await invoke_use(self, 1031, target=99999) + assert_content_contains(self.base_context, "Specified card is invalid") + + +class UseSpell1032(TestingUseSpell): + @test + async def adds_random_card(self) -> None: + await setup_author_spell(self.base_author.id, 1032) + lottery_card = Card.find( + lambda c: c["type"] == "normal" + and c["available"] is True + and c["rank"] != "SS" + and c["id"] != 0 + )[0] + + async def _no_owners(_self): + return [] + + with patch_random_choice(lottery_card): + with patch.object(Card, "owners", _no_owners): + await invoke_use(self, 1032) + assert_content_contains(self.base_context, "Successfully added card No.") + + @test + async def full_free_slots(self) -> None: + user = await setup_author_spell(self.base_author.id, 1032) + for i in range(FREE_SLOTS): + try: + await user.add_card(1000 + (i % 50)) + except Exception: + break + await invoke_use(self, 1032) + assert_content_contains(self.base_context, "don't have any space") + + +class UseSpell1035(TestingUseSpell): + @test + async def protects_page(self) -> None: + await setup_author_spell(self.base_author.id, 1035) + await invoke_use(self, 1035, target=3) + assert_content_contains(self.base_context, "Page 3 is now permanently protected") + user = await reload_user(self.base_author.id) + assert user.has_effect("page_protection_3")[0] + + @test + async def page_out_of_range(self) -> None: + await setup_author_spell(self.base_author.id, 1035) + await invoke_use(self, 1035, target=9) + assert_content_contains(self.base_context, "between 1 and 6") + + @test + async def already_protected(self) -> None: + user = await setup_author_spell(self.base_author.id, 1035) + await user.add_effect("page_protection_2", datetime.now()) + await invoke_use(self, 1035, target=2) + assert_content_contains(self.base_context, "already have this effect") + + +class UseSpell1036(TestingUseSpell): + @test + async def analysis_after_unlock(self) -> None: + user = await setup_author_spell(self.base_author.id, 1036) + await user.add_effect("1036", datetime.now()) + + async def _no_owners(_self): + return [] + + with patch.object(Card, "owners", _no_owners): + await invoke_use(self, 1036, target="analysis", args=11) + assert_embed_title(embed_at(self.base_context), "Info about card 11") + + @test + async def invalid_effect(self) -> None: + user = await setup_author_spell(self.base_author.id, 1036) + await user.add_effect("1036", datetime.now()) + await invoke_use(self, 1036, target="bogus", args=11) + assert_content_contains(self.base_context, "Invalid effect to use") + + @test + async def not_unlocked(self) -> None: + user = await User.new(self.base_author.id) + await user.nuke_cards("all") + await invoke_use(self, 1036, target="list", args=11) + assert_content_contains(self.base_context, "need to have used the card 1036 once") + + +class UseSpell1038(TestingUseSpell): + @test + async def list_embed(self) -> None: + await setup_author_spell(self.base_author.id, 1038) + + async def _no_owners(_self): + return [] + + with patch.object(Card, "owners", _no_owners): + await invoke_use(self, 1038, target=11) + assert_embed_title(embed_at(self.base_context), "Infos about card") + + @test + async def card_id_zero(self) -> None: + await setup_author_spell(self.base_author.id, 1038) + await self.command(self.cog, self.base_context, item="1038", args=0) + assert_content_contains(self.base_context, "less than 1") + + @test + async def invalid_card(self) -> None: + await setup_author_spell(self.base_author.id, 1038) + await invoke_use(self, 1038, target=99999) + assert_content_contains(self.base_context, "Specified card is invalid") + + +class UseDefense1003(TestingUseSpell): + @test + async def blocks_attack(self) -> None: + _, tid = await run_attack_against_defender(self, defense_id=1003) + await assert_steal_blocked_by_defense( + self.base_context, self.base_author.id, tid, STEAL_TARGET_CARD + ) + + @test + async def attack_proceeds_on_timeout(self) -> None: + _, tid = await run_attack_against_defender( + self, defense_id=1003, use_defense=False + ) + assert "successfully defended" not in last_content(self.base_context).lower() + await assert_steal_succeeded( + self.base_context, + self.base_author.id, + tid, + STEAL_TARGET_CARD, + message_fragment="Stole", + ) + + +class UseDefense1004(TestingUseSpell): + @test + async def blocks_when_met(self) -> None: + _, tid = await run_attack_against_defender( + self, defense_id=1004, attacker_in_met=True + ) + await assert_steal_blocked_by_defense( + self.base_context, self.base_author.id, tid, STEAL_TARGET_CARD + ) + + @test + async def attack_succeeds_when_not_met(self) -> None: + target, tid = target_member(self, 50_104) + await setup_author_spell(self.base_author.id, DEFAULT_ATTACK_SPELL) + await setup_target_user( + tid, + rs_cards=[STEAL_TARGET_CARD], + defense_ids=[1004], + met_attacker=False, + ) + await invoke_use( + self, DEFAULT_ATTACK_SPELL, target=target, args=STEAL_TARGET_CARD + ) + await assert_steal_succeeded( + self.base_context, + self.base_author.id, + tid, + STEAL_TARGET_CARD, + message_fragment="Stole card number", + ) + + +class UseDefense1019(TestingUseSpell): + @test + async def blocks_sr_attack(self) -> None: + _, tid = await run_attack_against_defender(self, defense_id=1019) + await assert_steal_blocked_by_defense( + self.base_context, self.base_author.id, tid, STEAL_TARGET_CARD + ) + + @test + async def no_block_non_sr_range(self) -> None: + _, tid = await run_attack_against_defender( + self, defense_id=1019, patch_attacker_range="B" + ) + await assert_steal_succeeded( + self.base_context, + self.base_author.id, + tid, + STEAL_TARGET_CARD, + message_fragment="Stole card number", + ) + + +class UseDefense1025(TestingUseSpell): + @test + async def blocks_view_spell(self) -> None: + target, tid = target_member(self, 50_125) + await setup_author_spell(self.base_author.id, 1001, met_ids=[tid]) + await setup_target_user( + tid, + fs_cards=[1011], + defense_ids=[1025], + met_attacker=True, + attacker_id=self.base_author.id, + ) + + async def _def(ctx): + await respond_defense_with_spell(ctx, 1025) + + with respond_to_view(self.base_context, _def): + await invoke_use(self, 1001, target=target) + assert_content_contains(self.base_context, "successfully defended") + await assert_inventory(tid, has=[1011]) diff --git a/killua/tests/groups/deep_coverage.py b/killua/tests/groups/deep_coverage.py new file mode 100644 index 000000000..3363b5fa0 --- /dev/null +++ b/killua/tests/groups/deep_coverage.py @@ -0,0 +1,83 @@ +"""Additional command and util coverage for large cogs.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +from discord.ext import commands + +from ..testing import Testing, test, collect_test_classes +from ..types import Bot +from ...cogs.dev import Dev +from ...cogs.events import Events +from ...cogs.shop import Shop +from ...static.constants import DB +from ...utils.checks import check +from ...utils.classes import User, Guild + + +class TestingDeep(Testing): + _menus_registered = False + + def __init__(self) -> None: + if not TestingDeep._menus_registered: + TestingDeep._menus_registered = True + else: + Shop._init_menus = lambda self: None + super().__init__(cog=Shop) + + @property + def all_tests(self): + return collect_test_classes(self.__class__) + + +class _DeepTests(TestingDeep): + requires_command = False + + +class DevDeep(_DeepTests): + requires_command = False + + def __init__(self) -> None: + super().__init__() + self._dev = Dev(Bot) + + @test + async def dev_stats_command(self) -> None: + ctx = self.base_context + + class _StatsCmd: + name = "stats" + + ctx.command = Bot.get_command("stats") or _StatsCmd() + with patch.object(self._dev, "initial_top", AsyncMock()) as top: + await self._dev.stats.callback(self._dev, ctx, "usage") + top.assert_awaited_once() + +class EventsDeep(_DeepTests): + @test + async def events_date_and_author(self) -> None: + events = Events(Bot) + assert events._date_helper(0) == 0 + ix = MagicMock() + ix.user.id = self.base_author.id + assert events.is_author(ix, str(self.base_author.id)) + + @test + async def events_missing_required_arg(self) -> None: + events = Events(Bot) + ctx = self.base_context + ctx.command = Bot.get_command("ping") + await events.on_command_error( + ctx, commands.MissingRequiredArgument(param=MagicMock(name="text")) + ) + assert "missed a required argument" in ctx.result.message.content + + +class ChecksDeep(_DeepTests): + @test + async def check_decorator_wraps_command(self) -> None: + decorated = check(0) + assert callable(decorated) diff --git a/killua/tests/groups/dev.py b/killua/tests/groups/dev.py index 9eafc4875..6e3b96c02 100644 --- a/killua/tests/groups/dev.py +++ b/killua/tests/groups/dev.py @@ -1,21 +1,28 @@ from ..types import * +from ...utils.classes import * from ..testing import Testing, test from ...cogs.dev import Dev -from ...static.constants import DB +from ...static.constants import DB, INFO + +from datetime import datetime +from unittest.mock import AsyncMock class TestingDev(Testing): + requires_command = True def __init__(self): super().__init__(cog=Dev) + def _mock_cog_externals(self): + pass + class Eval(TestingDev): def __init__(self): super().__init__() - # more a formality, this command is not really complicated @test async def eval(self) -> None: await self.command(self.cog, self.base_context, code="1+1") @@ -24,13 +31,20 @@ async def eval(self) -> None: self.base_context.result.message.content == "```py" + "\n" + "2```" ), self.base_context.result.message.content + @test + async def eval_exception(self) -> None: + await self.command(self.cog, self.base_context, code="1/0") + + assert ( + self.base_context.result.message.content == "division by zero" + ), self.base_context.result.message.content + class Say(TestingDev): def __init__(self): super().__init__() - # Same as eval, not a complicated command, not many tests @test async def say(self) -> None: await self.command(self.cog, self.base_context, content="Hello World") @@ -40,56 +54,143 @@ async def say(self) -> None: ), self.base_context.result.message.content -class Publish_Update(TestingDev): +class Update(TestingDev): def __init__(self): super().__init__() @test - async def publish_already_published_version(self) -> None: - DB.const._collection = [{"_id": "updates", "updates": [{"version": "1.0"}]}] - await self.command(self.cog, self.base_context, version="1.0", update="Test") + async def version_not_found(self) -> None: + DB.news.db["news"] = [] + await self.command(self.cog, self.base_context, version="nonexistent") assert ( self.base_context.result.message.content - == "This is an already existing version" + == "That version does not exist" ), self.base_context.result.message.content @test - async def publish_update(self) -> None: - DB.const._collection = [{"_id": "updates", "past_updates": []}] - await self.command(self.cog, self.base_context, version="1.0", update="test") + async def no_version_no_updates(self) -> None: + DB.news.db["news"] = [] + await self.command(self.cog, self.base_context) assert ( - self.base_context.result.message.content == "Published update" + self.base_context.result.message.content == "No updates found" ), self.base_context.result.message.content - assert DB.const.find_one({"_id": "updates"})["updates"][0]["version"], "1.0" + @test + async def correct_usage(self) -> None: + DB.news.db["news"] = [ + { + "_id": "test_update_1", + "type": "update", + "published": True, + "version": "1.0", + "title": "Test Update", + "content": "Some update content", + "author": "tester", + "images": [], + "links": {}, + "notify_users": [], + "timestamp": datetime.now(), + } + ] -class Update(TestingDev): + await self.command(self.cog, self.base_context, version="1.0") + + assert self.base_context.current_view is not None, ( + "Expected a LayoutView to be sent" + ) + + +class Info(TestingDev): + + def __init__(self): + super().__init__() + + @test + async def sends_info_embed(self) -> None: + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + self.base_context.result.message.embeds[0].title == "Infos about the bot" + ), self.base_context.result.message.embeds[0].title + assert ( + self.base_context.result.message.embeds[0].description == INFO + ), self.base_context.result.message.embeds[0].description + + +class Apistats(TestingDev): + command_name = "api_stats" + + def __init__(self): + super().__init__() + + @test + async def fetches_diagnostics(self) -> None: + class Resp: + status = 200 + + async def json(self): + return {"usage": {}, "ipc": {}} + + self.cog.client.session.get = AsyncMock(return_value=Resp()) + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.embeds or self.base_context.result.message.content + + +class Voteremind(TestingDev): def __init__(self): super().__init__() @test - async def incorrect_usage(self) -> None: - await self.command(self.cog, self.base_context, version="incorrect") + async def enable_when_disabled(self) -> None: + user = await User.new(self.base_author.id) + user.voting_reminder = False + + await self.command(self.cog, self.base_context, toggle="on") assert ( - self.base_context.result.message.content == "Invalid version!" + self.base_context.result.message.content + == "Enabled the voteremind! You can turn it off any time with this command!" ), self.base_context.result.message.content @test - async def correct_usage(self) -> None: - await self.command(self.cog, self.base_context, version="1.0") + async def enable_when_already_enabled(self) -> None: + user = await User.new(self.base_author.id) + user.voting_reminder = True + + await self.command(self.cog, self.base_context, toggle="on") assert ( - self.base_context.result.message.embeds - ), self.base_context.result.message.embeds + self.base_context.result.message.content + == "You already have the voteremind enabled!" + ), self.base_context.result.message.content + + @test + async def disable_when_enabled(self) -> None: + user = await User.new(self.base_author.id) + user.voting_reminder = True + + await self.command(self.cog, self.base_context, toggle="off") + assert ( - self.base_context.result.message.embeds[0].title - == "Infos about version `1.0`" - ), self.base_context.result.message.embeds[0].title + self.base_context.result.message.content + == "Disabled the voteremind! You can turn it back on any time with this command!" + ), self.base_context.result.message.content + + @test + async def disable_when_already_disabled(self) -> None: + user = await User.new(self.base_author.id) + user.voting_reminder = False + + await self.command(self.cog, self.base_context, toggle="off") + assert ( - self.base_context.result.message.embeds[0].description == "test" - ), self.base_context.result.message.embeds[0].description + self.base_context.result.message.content + == "You already have the voteremind disabled!" + ), self.base_context.result.message.content diff --git a/killua/tests/groups/economy.py b/killua/tests/groups/economy.py new file mode 100644 index 000000000..fd6679f26 --- /dev/null +++ b/killua/tests/groups/economy.py @@ -0,0 +1,387 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.economy import Economy +from ...static.constants import LOOTBOXES, BOOSTERS +from ...utils.classes.guild import Guild as KilluaGuild + +from datetime import datetime, timedelta +from unittest.mock import patch + +from ...utils.classes import lootbox as lootbox_mod + + +def _expected_boxinfo_contains(data: dict) -> str: + """Same 'Contains' text as `Economy.boxinfo` — keep in sync with that command.""" + c_min, c_max = data["cards_total"] + j_min, j_max = data["rewards"]["jenny"] + b_min, b_max = data["boosters_total"] + return ( + f"{data['rewards_total']} total rewards\n{f'{c_min}-{c_max}' if c_max != c_min else c_min} cards\n" + + ( + f"{j_min}-{j_max} jenny per field\n" + if j_max > 0 + else "No jenny in this box\n" + ) + + ( + "" + if c_max == 0 + else ( + f"card rarities: {' or '.join(data['rewards']['cards']['rarities'])}\n" + f"card types: {' or '.join(data['rewards']['cards']['types'])}" + ) + ) + + ( + ( + (f"{b_min}" if b_min == b_max else f"\n{b_min}-{b_max}") + + f" boosters\nAvailable boosters: {' '.join([BOOSTERS[int(x)]['emoji'] for x in data['rewards']['boosters']])}" + ) + if b_max != 0 + else "" + ) + ) + + +class TestingEconomy(Testing): + requires_command = True + _menus_registered = False + + def __init__(self): + if not TestingEconomy._menus_registered: + TestingEconomy._menus_registered = True + else: + Economy._init_menus = lambda self: None + super().__init__(cog=Economy) + + +class Jenny(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def no_user_arg(self) -> None: + user = await User.new(self.base_author.id) + balance = user.jenny + + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.content + == f"{self.base_author.display_name}'s balance is {balance} Jenny" + ), self.base_context.result.message.content + + @test + async def user_not_found(self) -> None: + original = self.cog.client.find_user + + async def _mock_find_user(ctx, user): + return None + + self.cog.client.find_user = _mock_find_user + await self.command(self.cog, self.base_context, user="nonexistent") + self.cog.client.find_user = original + + assert ( + self.base_context.result.message.content == "User not found" + ), self.base_context.result.message.content + + +class Daily(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def cooldown_active(self) -> None: + user = await User.new(self.base_author.id) + user.daily_cooldown = datetime.now() + timedelta(hours=1) + await user._update_val("cooldowndaily", user.daily_cooldown) + + await self.command(self.cog, self.base_context) + + assert ( + "You can claim your daily Jenny the next time" + in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def available(self) -> None: + KilluaGuild.cache.clear() + user = await User.new(self.base_author.id) + user.daily_cooldown = datetime.now() - timedelta(hours=1) + await user._update_val("cooldowndaily", user.daily_cooldown) + + await self.command(self.cog, self.base_context) + + assert ( + "You claimed your" in self.base_context.result.message.content + and "daily Jenny" in self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Inventory(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def empty_inventory(self) -> None: + user = await User.new(self.base_author.id) + for lb in list(user.lootboxes): + await user.remove_lootbox(lb) + for b in list(user.boosters.keys()): + while user.boosters.get(b, 0) > 0: + await user.use_booster(int(b)) + + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.content + == "Sadly you don't have any lootboxes or boosters!" + ), self.base_context.result.message.content + + @test + async def non_empty_inventory(self) -> None: + user = await User.new(self.base_author.id) + first_box_id = next(iter(LOOTBOXES.keys())) + await user.add_lootbox(first_box_id) + + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + self.base_context.result.message.embeds[0].title == "Lootbox inventory" + ), self.base_context.result.message.embeds[0].title + + await user.remove_lootbox(first_box_id) + + +class Profile(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def self_profile(self) -> None: + KilluaGuild.cache.clear() + await User.new(self.base_author.id) + await self.command(self.cog, self.base_context, user=None) + embeds = self.base_context.result.message.embeds + embed = embeds[-1] if isinstance(embeds, list) else embeds[0] + assert "Information about" in embed.title, embed.title + + @test + async def user_not_found(self) -> None: + KilluaGuild.cache.clear() + original = self.cog.client.find_user + + async def _mock_find_user(ctx, user): + return None + + self.cog.client.find_user = _mock_find_user + await self.command(self.cog, self.base_context, user="notauser99999") + self.cog.client.find_user = original + + assert ( + "Could not find user" in self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Boxinfo(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def invalid_box(self) -> None: + await self.command(self.cog, self.base_context, box="999") + + assert ( + self.base_context.result.message.content == "Invalid box name or id" + ), self.base_context.result.message.content + + @test + async def valid_box(self) -> None: + box_id = 1 + data = LOOTBOXES[box_id] + await self.command(self.cog, self.base_context, box=str(box_id)) + + embed = self.base_context.result.message.embeds[0] + assert embed.title == f"Infos about lootbox {data['emoji']} {data['name']}", ( + embed.title + ) + assert embed.description == data["description"], embed.description + + by_name = {f.name: f.value for f in embed.fields} + assert by_name["Contains"] == _expected_boxinfo_contains(data), ( + by_name["Contains"], + _expected_boxinfo_contains(data), + ) + assert str(by_name["Price"]) == str(data["price"]), by_name["Price"] + assert by_name["Buyable"] == ("Yes" if data["available"] else "No"), ( + by_name["Buyable"] + ) + + +class Boosterinfo(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def invalid_booster(self) -> None: + await self.command(self.cog, self.base_context, booster="999") + + assert ( + self.base_context.result.message.content == "Invalid booster name or id" + ), self.base_context.result.message.content + + @test + async def valid_booster(self) -> None: + await self.command(self.cog, self.base_context, booster="1") + + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + + +class Open(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def no_lootboxes(self) -> None: + user = await User.new(self.base_author.id) + for lb in list(user.lootboxes): + await user.remove_lootbox(lb) + + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.content + == "Sadly you don't have any lootboxes!" + ), self.base_context.result.message.content + + @test + async def open_select_lootbox_runs_stub_open(self) -> None: + """Path A: lootbox Select + view.wait; LootBox.open/generate_rewards stubbed.""" + from killua.utils.interactions import Select as KSelect + + user = await User.new(self.base_author.id) + for lb in list(user.lootboxes): + await user.remove_lootbox(lb) + await user.add_lootbox(1) + + async def fake_gen(cls, box): + return [10] + + async def fake_open(self): + await self.ctx.send("stub-lootbox-opened") + + orig_gen = lootbox_mod.LootBox.generate_rewards + orig_open = lootbox_mod.LootBox.open + lootbox_mod.LootBox.generate_rewards = classmethod(fake_gen) # type: ignore[assignment] + lootbox_mod.LootBox.open = fake_open # type: ignore[assignment] + + self.base_context.timeout_view = False + + async def _pick(ctx): + v = ctx.current_view + if not v: + return + for item in v.children: + if isinstance(item, KSelect): + await item.callback( + ArgumentInteraction( + ctx, + message=ctx.result.message, + data={"values": ["1"]}, + ) + ) + break + if ctx.current_view: + ctx.current_view.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _pick + try: + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context) + finally: + lootbox_mod.LootBox.generate_rewards = orig_gen + lootbox_mod.LootBox.open = orig_open + self.base_context.respond_to_view = _prev_rtv + + assert "stub-lootbox-opened" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Guild(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def shows_guild_embed(self) -> None: + await KilluaGuild.new(self.base_guild.id) + + async def _lb(ctx, limit=10): + return { + "points": 250, + "top": [{"name": "RichUser", "points": 120}], + } + + self.cog._lb = _lb + await self.command(self.cog, self.base_context) + + emb = self.base_context.result.message.embeds + if isinstance(emb, tuple): + emb = emb[0] + assert emb[0].title.startswith("Information about"), emb[0].title + fields = {f.name: f.value for f in emb[0].fields} + assert str(fields["Combined Jenny"]) == "250" + assert "RichUser" in fields["Richest Member"] + + +class Leaderboard(TestingEconomy): + + def __init__(self): + super().__init__() + + @test + async def nobody_has_jenny(self) -> None: + async def _lb(ctx, limit=10): + return {"points": 0, "top": []} + + self.cog._lb = _lb + await self.command(self.cog, self.base_context) + + assert ( + "Nobody here has any jenny" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def lists_top_members(self) -> None: + async def _lb(ctx, limit=10): + return { + "points": 900, + "top": [ + {"name": "First", "points": 500}, + {"name": "Second", "points": 300}, + ], + } + + self.cog._lb = _lb + await self.command(self.cog, self.base_context) + + emb = self.base_context.result.message.embeds + if isinstance(emb, tuple): + emb = emb[0] + assert "Top users" in emb[0].title + assert "#1 `First` with `500` jenny" in emb[0].description + assert "#2 `Second` with `300` jenny" in emb[0].description diff --git a/killua/tests/groups/events.py b/killua/tests/groups/events.py new file mode 100644 index 000000000..15ae58865 --- /dev/null +++ b/killua/tests/groups/events.py @@ -0,0 +1,295 @@ +"""Events cog listener and poll/wyr interaction tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import discord +from discord.ext import commands + +from ..testing import Testing, test, collect_test_classes +from ..types import Bot, DiscordMember +from ...cogs.events import Events +from ...utils.classes.guild import Guild as KilluaGuild +from ..harnesses import ( + ListenerFakeButton, + ListenerFakeRow, + MockComponentInteraction, + build_poll_message, + build_wyr_message, + cast_vote, + encrypted_tail_on_button, + option_button_custom_id, +) +class TestingEvents(Testing): + def __init__(self) -> None: + super().__init__(cog=Events) + + @property + def all_tests(self): + return collect_test_classes(self.__class__) + + +class _EventsTests(TestingEvents): + pass + + +class PollInteractionTests(_EventsTests): + @test + async def author_cannot_vote(self) -> None: + await KilluaGuild.new(self.base_guild.id) + events = self.cog + enc = Bot._encrypt(self.base_author.id, smallest=False) + emb = discord.Embed(title="Poll", description="Q?", color=0x3E4A78) + emb.add_field(name="1) One `[0 votes]`", value="—", inline=False) + sty = int(discord.ButtonStyle.blurple) + row = ListenerFakeRow( + [ + ListenerFakeButton(custom_id="poll:opt-1:", label="1", style=sty), + ListenerFakeButton( + custom_id=f"poll:close:{enc}:", label="Close", style=sty + ), + ] + ) + + class PM: + id = 88001 + embeds = [emb] + components = [row] + + ix = MockComponentInteraction( + context=self.base_context, + custom_id="poll:opt-1:", + user=self.base_author, + message=PM(), + client=Bot, + ) + await events.on_interaction(ix) + assert ix.response.is_done() + + @test + async def close_requires_author(self) -> None: + await KilluaGuild.new(self.base_guild.id) + events = self.cog + voter = DiscordMember(guild=self.base_guild, id=self.base_author.id + 1) + enc = Bot._encrypt(self.base_author.id, smallest=False) + emb = discord.Embed(title="Poll", color=0x3E4A78) + sty = int(discord.ButtonStyle.blurple) + row = ListenerFakeRow( + [ + ListenerFakeButton(custom_id="poll:opt-1:", label="1", style=sty), + ListenerFakeButton( + custom_id=f"poll:close:{enc}:", label="Close", style=sty + ), + ] + ) + + class PM: + id = 88002 + embeds = [emb] + components = [row] + + ix = MockComponentInteraction( + context=self.base_context, + custom_id=f"poll:close:{enc}:", + user=voter, + message=PM(), + client=Bot, + ) + await events.on_interaction(ix) + assert ix.response.is_done() + + @test + async def wyr_option_b_vote(self) -> None: + await KilluaGuild.new(self.base_guild.id) + voter = DiscordMember(guild=self.base_guild, id=self.base_author.id + 2) + emb = discord.Embed(title="Would you rather...", color=0x3E4A78) + emb.add_field(name="A) left `[0 people]`", value="—", inline=False) + emb.add_field(name="B) right `[0 people]`", value="—", inline=False) + sty = int(discord.ButtonStyle.blurple) + row = ListenerFakeRow( + [ + ListenerFakeButton(custom_id="wyr:opt-a:", label="A", style=sty), + ListenerFakeButton(custom_id="wyr:opt-b:", label="B", style=sty), + ] + ) + + class PM: + id = 88003 + embeds = [emb] + components = [row] + + events = self.cog + with patch("killua.bot.randint", return_value=100): + await events.on_interaction( + MockComponentInteraction( + context=self.base_context, + custom_id="wyr:opt-b:", + user=voter, + message=PM(), + client=Bot, + ) + ) + + +class PollWyrEncryptionTests(_EventsTests): + @test + async def poll_sixth_vote_uses_button_encryption(self) -> None: + guild = await KilluaGuild.new(self.base_guild.id) + guild.badges = [b for b in guild.badges if b not in ("premium", "partner")] + guild.polls = {} + author_id = self.base_author.id + existing = [author_id + 10 + i for i in range(5)] + msg = build_poll_message( + author_id, visible_voter_ids=existing, option_index=1 + ) + sixth = DiscordMember( + guild=self.base_guild, id=author_id + 99, username="V6" + ) + events = self.cog + prefix = "poll:opt-1:" + before = option_button_custom_id(msg, 1) + assert before == prefix, before + + with patch("killua.bot.randint", return_value=100): + await cast_vote( + events, + context=self.base_context, + message=msg, + voter=sixth, + custom_id=prefix, + ) + + after = option_button_custom_id(msg, 1) + tail = encrypted_tail_on_button(after, prefix) + assert len(tail) > 0, after + assert Bot._encrypt(sixth.id) in tail.split(","), after + + @test + async def wyr_sixth_vote_uses_button_encryption(self) -> None: + guild = await KilluaGuild.new(self.base_guild.id) + guild.badges = [b for b in guild.badges if b not in ("premium", "partner")] + guild.polls = {} + existing = [self.base_author.id + 20 + i for i in range(5)] + msg = build_wyr_message(side="b", visible_voter_ids=existing) + sixth = DiscordMember( + guild=self.base_guild, id=self.base_author.id + 199, username="W6" + ) + events = self.cog + prefix = "wyr:opt-b:" + before = msg.components[0].children[1].custom_id + assert before == prefix, before + + with patch("killua.bot.randint", return_value=100): + await cast_vote( + events, + context=self.base_context, + message=msg, + voter=sixth, + custom_id=prefix, + ) + + after = msg.components[0].children[1].custom_id + tail = encrypted_tail_on_button(after, prefix) + assert len(tail) > 0, after + assert Bot._encrypt(sixth.id) in tail.split(","), after + + @test + async def poll_premium_vote_persisted_in_db(self) -> None: + guild = await KilluaGuild.new(self.base_guild.id) + await guild.add_premium() + author_id = self.base_author.id + msg_id = 99010 + msg = build_poll_message(author_id, message_id=msg_id, option_count=2) + await guild.add_poll( + str(msg_id), + {"author": author_id, "votes": {"0": [], "1": []}}, + ) + voter = DiscordMember( + guild=self.base_guild, id=author_id + 50, username="PremVoter" + ) + + with patch("killua.bot.randint", return_value=100): + await cast_vote( + self.cog, + context=self.base_context, + message=msg, + voter=voter, + custom_id="poll:opt-1:", + ) + + refreshed = await KilluaGuild.new(self.base_guild.id) + assert voter.id in refreshed.polls[str(msg_id)]["votes"]["0"], ( + refreshed.polls[str(msg_id)]["votes"] + ) + + @test + async def poll_non_premium_stays_embed_path(self) -> None: + guild = await KilluaGuild.new(self.base_guild.id) + assert not guild.is_premium + author_id = self.base_author.id + msg_id = 99011 + msg = build_poll_message(author_id, message_id=msg_id) + voter = DiscordMember( + guild=self.base_guild, id=author_id + 51, username="FreeVoter" + ) + + with patch("killua.bot.randint", return_value=100): + await cast_vote( + self.cog, + context=self.base_context, + message=msg, + voter=voter, + custom_id="poll:opt-1:", + ) + + refreshed = await KilluaGuild.new(self.base_guild.id) + assert str(msg_id) not in refreshed.polls + field_value = msg.embeds[0].fields[0].value + assert f"<@{voter.id}>" in field_value, field_value + + +class CommandErrorTests(_EventsTests): + @test + async def bot_missing_permissions(self) -> None: + events = self.cog + ctx = self.base_context + ctx.command = Bot.get_command("ping") + ctx.me.guild_permissions = [("send_messages", True)] + await events.on_command_error( + ctx, commands.BotMissingPermissions(missing_permissions=["manage_messages"]) + ) + assert "don't have the required permissions" in ctx.result.message.content + + @test + async def missing_permissions(self) -> None: + events = self.cog + ctx = self.base_context + ctx.command = Bot.get_command("ping") + await events.on_command_error( + ctx, commands.MissingPermissions(missing_permissions=["ban_members"]) + ) + assert "You don't have the required permissions" in ctx.result.message.content + + @test + async def command_not_found_silent(self) -> None: + events = self.cog + ctx = self.base_context + ctx.command = None + before = len(ctx.result.message.content or "") + await events.on_command_error(ctx, commands.CommandNotFound("nope")) + assert (ctx.result.message.content or "") == "" or len(ctx.result.message.content or "") >= before + + @test + async def date_helper(self) -> None: + events = self.cog + assert events._date_helper(15) == 3 + assert events._date_helper(9) == 9 + + @test + async def is_author_numeric(self) -> None: + events = self.cog + enc = str(self.base_author.id) + interaction = MagicMock() + interaction.user.id = self.base_author.id + assert events.is_author(interaction, enc) diff --git a/killua/tests/groups/games.py b/killua/tests/groups/games.py new file mode 100644 index 000000000..d0d0c1935 --- /dev/null +++ b/killua/tests/groups/games.py @@ -0,0 +1,804 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.games import Games +from ...static.constants import TRIVIA_TOPICS, DB +from ...utils.test_db import TestingDatabase +from ..types.member import TestingMember +from unittest.mock import AsyncMock, patch + + +def _seed_teams(docs: list) -> None: + TestingDatabase.db["teams"] = [] + for d in docs: + TestingDatabase.db["teams"].append(d) + + +def _last_embed_title_description(message): + raw = message.embeds + embed = None + if isinstance(raw, list) and raw: + embed = raw[-1] + elif isinstance(raw, tuple) and raw: + inner = raw[0] + if isinstance(inner, list) and inner: + embed = inner[-1] + assert embed is not None, raw + return embed.title or "", embed.description or "" + + +async def _instant_sleep(*_a, **_k): + return None + + +class TestingGames(Testing): + requires_command = True + + def __init__(self): + super().__init__(cog=Games) + + +class Gstats(TestingGames): + + def __init__(self): + super().__init__() + + @test + async def rps_stats(self) -> None: + await self.command(self.cog, self.base_context, game_type="rps") + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "RPS stats" in self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + @test + async def trivia_stats(self) -> None: + await self.command(self.cog, self.base_context, game_type="trivia") + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "Trivia stats" in self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + @test + async def counting_stats(self) -> None: + await self.command(self.cog, self.base_context, game_type="counting") + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "Counting stats" in self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + +class Gleaderboard(TestingGames): + + def __init__(self): + super().__init__() + + @test + async def rps_global(self) -> None: + _seed_teams( + [ + { + "id": 501001, + "points": 10, + "stats": { + "rps": {"pve": {"won": 3}, "pvp": {"won": 20}}, + "trivia": { + "easy": {"right": 1}, + "medium": {"right": 0}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 1, "hard": 0}, + }, + }, + { + "id": 501002, + "points": 5, + "stats": { + "rps": {"pve": {"won": 9}, "pvp": {"won": 1}}, + "trivia": { + "easy": {"right": 2}, + "medium": {"right": 1}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 3, "hard": 2}, + }, + }, + ] + ) + await self.command( + self.cog, self.base_context, game="rps", where="global" + ) + title, desc = _last_embed_title_description(self.base_context.result.message) + assert "leaderboard" in title.lower(), title + assert "<@501001>" in desc or "<@501002>" in desc, desc + + @test + async def rps_server_filters_members(self) -> None: + in_guild = TestingMember(id=601001, username="A") + self.base_guild.members = [self.base_author, in_guild] + _seed_teams( + [ + { + "id": 601001, + "points": 1, + "stats": { + "rps": {"pve": {"won": 1}, "pvp": {"won": 1}}, + "trivia": { + "easy": {"right": 0}, + "medium": {"right": 0}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 0, "hard": 0}, + }, + }, + { + "id": 609999, + "points": 99, + "stats": { + "rps": {"pve": {"won": 99}, "pvp": {"won": 99}}, + "trivia": { + "easy": {"right": 0}, + "medium": {"right": 0}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 0, "hard": 0}, + }, + }, + ] + ) + await self.command( + self.cog, self.base_context, game="rps", where="server" + ) + title, desc = _last_embed_title_description(self.base_context.result.message) + assert "leaderboard" in title.lower(), title + assert "<@601001>" in desc, desc + assert "609999" not in desc, desc + + @test + async def counting_global(self) -> None: + _seed_teams( + [ + { + "id": 701001, + "points": 1, + "stats": { + "rps": {"pve": {"won": 0}, "pvp": {"won": 0}}, + "trivia": { + "easy": {"right": 0}, + "medium": {"right": 0}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 50, "hard": 12}, + }, + }, + { + "id": 701002, + "points": 1, + "stats": { + "rps": {"pve": {"won": 0}, "pvp": {"won": 0}}, + "trivia": { + "easy": {"right": 0}, + "medium": {"right": 0}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 10, "hard": 99}, + }, + }, + ] + ) + await self.command( + self.cog, self.base_context, game="counting", where="global" + ) + title, desc = _last_embed_title_description(self.base_context.result.message) + assert "counting" in title.lower(), title + assert "<@701001>" in desc or "<@701002>" in desc, desc + + @test + async def trivia_global(self) -> None: + _seed_teams( + [ + { + "id": 801001, + "points": 1, + "stats": { + "rps": {"pve": {"won": 0}, "pvp": {"won": 0}}, + "trivia": { + "easy": {"right": 5}, + "medium": {"right": 1}, + "hard": {"right": 0}, + }, + "counting_highscore": {"easy": 0, "hard": 0}, + }, + }, + { + "id": 801002, + "points": 1, + "stats": { + "rps": {"pve": {"won": 0}, "pvp": {"won": 0}}, + "trivia": { + "easy": {"right": 2}, + "medium": {"right": 4}, + "hard": {"right": 1}, + }, + "counting_highscore": {"easy": 0, "hard": 0}, + }, + }, + ] + ) + await self.command( + self.cog, self.base_context, game="trivia", where="global" + ) + title, desc = _last_embed_title_description(self.base_context.result.message) + assert "trivia" in title.lower(), title + assert "<@801001>" in desc or "<@801002>" in desc, desc + + +class Rps(TestingGames): + + def __init__(self): + super().__init__() + + @test + async def play_against_self(self) -> None: + await self.command(self.cog, self.base_context, user=self.base_context.author) + assert ( + self.base_context.result.message.content + == "Baka! You can't play against yourself" + ), self.base_context.result.message.content + + @test + async def play_against_other_bot_rejected(self) -> None: + other_bot = DiscordMember( + id=self.base_context.me.id + 424242, + bot=True, + username="NotKillua", + mutual_guilds=[object()], + ) + await self.command(self.cog, self.base_context, user=other_bot) + assert "Beep-boop" in self.base_context.result.message.content + + @test + async def play_opponent_without_mutual_guilds(self) -> None: + opp = DiscordMember( + id=self.base_author.id + 8001, + bot=False, + username="Stranger", + mutual_guilds=[], + ) + await User.new(opp.id) + await self.command(self.cog, self.base_context, user=opp) + assert "share a server" in self.base_context.result.message.content.lower() + + @test + async def play_points_nonpositive_rejected(self) -> None: + opp = DiscordMember( + id=self.base_author.id + 8002, + bot=False, + username="Buddy", + mutual_guilds=[object()], + ) + self.base_guild.members = [self.base_author, opp] + await User.new(self.base_author.id) + await User.new(opp.id) + with patch("killua.cogs.games.blcheck", new_callable=AsyncMock, return_value=False): + await self.command(self.cog, self.base_context, user=opp, points=-1) + assert "1-100" in self.base_context.result.message.content + + @test + async def play_points_over_cap_rejected(self) -> None: + opp = DiscordMember( + id=self.base_author.id + 8003, + bot=False, + username="Buddy2", + mutual_guilds=[object()], + ) + self.base_guild.members = [self.base_author, opp] + await User.new(self.base_author.id) + await User.new(opp.id) + with patch("killua.cogs.games.blcheck", new_callable=AsyncMock, return_value=False): + await self.command(self.cog, self.base_context, user=opp, points=101) + assert "1-100" in self.base_context.result.message.content + + @test + async def play_points_author_too_poor(self) -> None: + opp = DiscordMember( + id=self.base_author.id + 8004, + bot=False, + username="RichFriend", + mutual_guilds=[object()], + ) + self.base_guild.members = [self.base_author, opp] + u1 = await User.new(self.base_author.id) + await u1.set_jenny(5) + await User.new(opp.id) + with patch("killua.cogs.games.blcheck", new_callable=AsyncMock, return_value=False): + await self.command(self.cog, self.base_context, user=opp, points=10) + assert "not have enough Jenny" in self.base_context.result.message.content + + @test + async def play_points_opponent_too_poor(self) -> None: + opp = DiscordMember( + id=self.base_author.id + 8005, + bot=False, + username="PoorFriend", + mutual_guilds=[object()], + ) + self.base_guild.members = [self.base_author, opp] + u1 = await User.new(self.base_author.id) + await u1.set_jenny(500) + u2 = await User.new(opp.id) + await u2.set_jenny(3) + with patch("killua.cogs.games.blcheck", new_callable=AsyncMock, return_value=False): + await self.command(self.cog, self.base_context, user=opp, points=10) + assert "opponent does not have enough" in self.base_context.result.message.content + + @test + async def pvp_accept_challenge_one_confirm(self) -> None: + """Opponent accepts channel confirm; both players pick via patched DM select.""" + from ..harnesses import find_button, patch_member_rps_select + + opp = DiscordMember( + id=self.base_author.id + 8010, + bot=False, + username="Rival", + mutual_guilds=[object()], + ) + self.base_guild.members = [self.base_author, opp] + u1 = await User.new(self.base_author.id) + await u1.set_jenny(500) + u2 = await User.new(opp.id) + await u2.set_jenny(500) + + await patch_member_rps_select(self.base_author, self.base_context, choice=0) + await patch_member_rps_select(opp, self.base_context, choice=1) + + async def accept_challenge(ctx): + button = find_button(ctx.current_view, custom_id="confirm") + if button: + await button.callback(ArgumentInteraction(ctx, user=opp)) + + self.base_context.timeout_view = False + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = accept_challenge + try: + with patch("killua.cogs.games.blcheck", AsyncMock(return_value=False)): + with patch( + "killua.cogs.games.Rps._will_exceed_interaction_limit", + return_value=True, + ): + with patch("killua.cogs.games.random.randint", return_value=1): + await self.command( + self.cog, self.base_context, user=opp, points=5 + ) + finally: + self.base_context.respond_to_view = _prev_rtv + + msg = self.base_context.result.message.content or "" + assert ( + "against" in msg.lower() + or "won" in msg.lower() + or "lost" in msg.lower() + or "=" in msg + ), msg + + @test + async def singleplayer_vs_killua_dm_select_outcome(self) -> None: + """Author picks via patched DM RpsSelect; bot choice patched; channel outcome.""" + from ..harnesses import patch_member_rps_select + + await patch_member_rps_select(self.base_author, self.base_context, choice=0) + + with patch( + "killua.cogs.games.Rps._will_exceed_interaction_limit", + return_value=True, + ): + with patch("killua.cogs.games.random.randint", return_value=1): + await self.command( + self.cog, self.base_context, user=self.base_context.me + ) + + msg = self.base_context.result.message.content or "" + assert ( + "against" in msg.lower() + or "won" in msg.lower() + or "lost" in msg.lower() + or "=" in msg + ), msg + + +class Trivia(TestingGames): + + def __init__(self): + super().__init__() + + @test + async def invalid_topic(self) -> None: + await self.command(self.cog, self.base_context, topic="TotallyFakeTopic123") + assert ( + "That is not a valid topic" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def single_wrong_answer_select(self) -> None: + """Path A: trivia question Select callback picks a wrong option.""" + from killua.utils.interactions import Select as KSelect + + api_payload = { + "response_code": 0, + "results": [ + { + "category": "9", + "type": "multiple", + "difficulty": "easy", + "question": "Test%3F", + "correct_answer": "Yes", + "incorrect_answers": ["No", "Maybe", "Perhaps"], + } + ], + } + + class Resp: + async def json(self): + return api_payload + + orig_get = self.cog.client.session.get + self.cog.client.session.get = AsyncMock(return_value=Resp()) + + self.base_context.timeout_view = False + phase_holder = {"n": 0} + + async def _answer_then_dismiss_play_again(ctx): + v = ctx.current_view + if not v: + return + if phase_holder["n"] == 0: + for item in v.children: + if isinstance(item, KSelect): + await item.callback( + ArgumentInteraction( + ctx, + message=ctx.result.message, + data={"values": ["0"]}, + ) + ) + break + phase_holder["n"] = 1 + else: + v.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _answer_then_dismiss_play_again + try: + with patch("killua.cogs.games.random.sample", lambda seq, k: list(seq)[:k]): + with patch("killua.bot.randint", return_value=7): + await self.command(self.cog, self.base_context, difficulty="easy") + finally: + self.cog.client.session.get = orig_get + self.base_context.respond_to_view = _prev_rtv + + assert "Sadly not the right answer" in ( + self.base_context.result.message.content or "" + ), self.base_context.result.message.content + + def _trivia_api_ok(self): + return { + "response_code": 0, + "results": [ + { + "category": "9", + "type": "multiple", + "difficulty": "easy", + "question": "Test%3F", + "correct_answer": "Yes", + "incorrect_answers": ["No", "Maybe", "Perhaps"], + } + ], + } + + @test + async def single_correct_answer_select(self) -> None: + """Path A: correct option updates stats and shows reward text.""" + from killua.utils.interactions import Select as KSelect + + class Resp: + def __init__(self, payload): + self._payload = payload + + async def json(self): + return self._payload + + api_payload = self._trivia_api_ok() + orig_get = self.cog.client.session.get + self.cog.client.session.get = AsyncMock(return_value=Resp(api_payload)) + + self.base_context.timeout_view = False + + async def _pick_correct(ctx): + v = ctx.current_view + if not v: + return + for item in v.children: + if isinstance(item, KSelect): + correct_i = next( + i for i, o in enumerate(item.options) if o.label == "Yes" + ) + await item.callback( + ArgumentInteraction( + ctx, + message=ctx.result.message, + data={"values": [str(correct_i)]}, + ) + ) + break + v.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _pick_correct + try: + with patch("killua.cogs.games.random.sample", lambda seq, k: list(seq)[:k]): + with patch("killua.bot.randint", return_value=7): + await self.command(self.cog, self.base_context, difficulty="easy") + finally: + self.cog.client.session.get = orig_get + self.base_context.respond_to_view = _prev_rtv + + assert "Correct!" in ( + self.base_context.result.message.content or "" + ), self.base_context.result.message.content + + @test + async def trivia_api_failure_message(self) -> None: + class Resp: + async def json(self): + return {"response_code": 5, "results": []} + + orig_get = self.cog.client.session.get + self.cog.client.session.get = AsyncMock(return_value=Resp()) + try: + await self.command(self.cog, self.base_context, difficulty="easy") + finally: + self.cog.client.session.get = orig_get + txt = (self.base_context.result.message.content or "").lower() + assert "issue with the api" in txt, self.base_context.result.message.content + + @test + async def play_again_triggers_second_round_then_wrong(self) -> None: + """Select correct, press Play Again, then wrong answer on second question.""" + from killua.utils.interactions import Select as KSelect + + from ..harnesses import find_button + + class Resp: + def __init__(self, payload): + self._payload = payload + + async def json(self): + return self._payload + + api_payload = self._trivia_api_ok() + orig_get = self.cog.client.session.get + self.cog.client.session.get = AsyncMock(return_value=Resp(api_payload)) + + self.base_context.timeout_view = False + phase = {"n": 0} + + async def _phased(ctx): + v = ctx.current_view + if not v: + return + if phase["n"] == 0: + for item in v.children: + if isinstance(item, KSelect): + correct_i = next( + i for i, o in enumerate(item.options) if o.label == "Yes" + ) + await item.callback( + ArgumentInteraction( + ctx, + message=ctx.result.message, + data={"values": [str(correct_i)]}, + ) + ) + break + phase["n"] = 1 + elif phase["n"] == 1: + btn = find_button(v, custom_id="play_again") + assert btn is not None + await btn.callback( + ArgumentInteraction(ctx, message=ctx.result.message) + ) + phase["n"] = 2 + else: + for item in v.children: + if isinstance(item, KSelect): + wrong_i = next( + i + for i, o in enumerate(item.options) + if o.label != "Yes" + ) + await item.callback( + ArgumentInteraction( + ctx, + message=ctx.result.message, + data={"values": [str(wrong_i)]}, + ) + ) + break + v.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _phased + try: + with patch("killua.cogs.games.random.sample", lambda seq, k: list(seq)[:k]): + with patch("killua.bot.randint", return_value=7): + await self.command( + self.cog, self.base_context, difficulty="easy" + ) + finally: + self.cog.client.session.get = orig_get + self.base_context.respond_to_view = _prev_rtv + + content = self.base_context.result.message.content or "" + assert "Sadly not the right answer" in content, content + + @test + async def multiplayer_both_wrong_after_dm_selects(self) -> None: + """PvP trivia: confirm, both answer via patched DM Select (wrong options).""" + from ..harnesses import find_button, patch_member_trivia_select + + opp = DiscordMember( + id=self.base_author.id + 8020, + bot=False, + username="TriviaRival", + mutual_guilds=[object()], + ) + self.base_guild.members = [self.base_author, opp] + u1 = await User.new(self.base_author.id) + await u1.set_jenny(500) + u2 = await User.new(opp.id) + await u2.set_jenny(500) + + class Resp: + def __init__(self, payload): + self._payload = payload + + async def json(self): + return self._payload + + api_payload = self._trivia_api_ok() + orig_get = self.cog.client.session.get + self.cog.client.session.get = AsyncMock(return_value=Resp(api_payload)) + + await patch_member_trivia_select( + self.base_author, self.base_context, choice_index=0 + ) + await patch_member_trivia_select(opp, self.base_context, choice_index=1) + + async def accept_challenge(ctx): + button = find_button(ctx.current_view, custom_id="confirm") + if button: + await button.callback(ArgumentInteraction(ctx, user=opp)) + + self.base_context.timeout_view = False + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = accept_challenge + try: + with patch("killua.cogs.games.blcheck", AsyncMock(return_value=False)): + with patch( + "killua.cogs.games.Trivia._will_exceed_interaction_limit", + return_value=True, + ): + with patch( + "killua.cogs.games.random.sample", + lambda seq, k: list(seq)[:k], + ): + with patch("killua.bot.randint", return_value=7): + await self.command( + self.cog, + self.base_context, + opponent=opp, + jenny=5, + difficulty="easy", + ) + finally: + self.cog.client.session.get = orig_get + self.base_context.respond_to_view = _prev_rtv + + msg = self.base_context.result.message.content or "" + assert "wrong answer" in msg.lower(), msg + + +class Count(TestingGames): + + def __init__(self): + super().__init__() + + @test + async def wrong_button_ends_run(self) -> None: + """Memorize phase sleeps patched; wrong CountButtons tap ends with Wrong choice.""" + from killua.cogs.games import CountButtons + + from ..harnesses import iter_view_items + + await User.new(self.base_author.id) + + self.base_context.timeout_view = False + + async def _wrong_first_click(ctx): + v = ctx.current_view + if not v: + return + ref = next( + (c for c in iter_view_items(v) if isinstance(c, CountButtons)), None + ) + assert ref is not None + stage = getattr(v, "stage", 1) + need = ref.solutions[stage] + wrong_btn = next( + c + for c in iter_view_items(v) + if isinstance(c, CountButtons) and c.index != need + ) + await wrong_btn.callback( + ArgumentInteraction(ctx, message=ctx.result.message) + ) + v.stop() + + _prev = self.base_context.respond_to_view + self.base_context.respond_to_view = _wrong_first_click + try: + with patch("killua.cogs.games.asyncio.sleep", _instant_sleep): + await self.command(self.cog, self.base_context, difficulty="easy") + finally: + self.base_context.respond_to_view = _prev + + txt = self.base_context.result.message.content or "" + assert "Wrong choice" in txt or "wrong" in txt.lower(), txt + + @test + async def correct_first_stage_shows_next_level_prompt(self) -> None: + from killua.cogs.games import CountButtons + + from ..harnesses import iter_view_items + + await User.new(self.base_author.id) + self.base_context.timeout_view = False + + async def _right_click(ctx): + v = ctx.current_view + if not v: + return + stage = getattr(v, "stage", 1) + ref = next( + (c for c in iter_view_items(v) if isinstance(c, CountButtons)), None + ) + assert ref is not None + need = ref.solutions[stage] + btn = next( + c + for c in iter_view_items(v) + if isinstance(c, CountButtons) and c.index == need + ) + await btn.callback(ArgumentInteraction(ctx, message=ctx.result.message)) + v.stop() + + _prev = self.base_context.respond_to_view + self.base_context.respond_to_view = _right_click + try: + with patch("killua.cogs.games.asyncio.sleep", _instant_sleep): + await self.command(self.cog, self.base_context, difficulty="easy") + finally: + self.base_context.respond_to_view = _prev + + txt = self.base_context.result.message.content or "" + assert ( + "next level" in txt.lower() + or "congrats" in txt.lower() + or "well done" in txt.lower() + ), txt diff --git a/killua/tests/groups/help.py b/killua/tests/groups/help.py new file mode 100644 index 000000000..0dab665fb --- /dev/null +++ b/killua/tests/groups/help.py @@ -0,0 +1,171 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.help import HelpCommand, HelpPaginator +from ...utils.classes.guild import Guild as KilluaGuild +from ...static.constants import DB +from ...static.enums import Category +from ...utils.paginator import Paginator +from ..harnesses import embed_footer_page, press_paginator_button + + +def _help_group_stub_cmd_alpha(): + """Alpha stub for help group formatting tests.""" + pass + + +def _help_group_stub_cmd_beta(): + """Beta stub for help group formatting tests.""" + pass + + +def _fake_commands_for_actions_help_group(): + return [ + SimpleNamespace( + callback=_help_group_stub_cmd_alpha, + name="hlp_alpha", + qualified_name="hlp_alpha", + help="Alpha stub for help group formatting tests.", + usage="hlp_alpha", + extras={"category": Category.ACTIONS, "id": 990101}, + hidden=False, + parent=None, + cog=None, + checks=[], + app_command=SimpleNamespace(parent=None), + ), + SimpleNamespace( + callback=_help_group_stub_cmd_beta, + name="hlp_beta", + qualified_name="hlp_beta", + help="Beta stub for help group formatting tests.", + usage="hlp_beta", + extras={"category": Category.ACTIONS, "id": 990102}, + hidden=False, + parent=None, + cog=None, + checks=[], + app_command=SimpleNamespace(parent=None), + ), + ] + + +def _reset_guild_state(): + KilluaGuild.cache.clear() + DB.guilds.db["guilds"] = [] + + +class TestingHelp(Testing): + requires_command = True + + def __init__(self): + super().__init__(cog=HelpCommand) + + +class Help(TestingHelp): + + def __init__(self): + super().__init__() + + @test + async def nonexistent_command(self) -> None: + _reset_guild_state() + + await self.command(self.cog, self.base_context, command="totally_fake_command_xyz") + + assert ( + self.base_context.result.message.content + == 'No command called "totally_fake_command_xyz" found.' + ), self.base_context.result.message.content + + @test + async def valid_command(self) -> None: + _reset_guild_state() + + await self.command(self.cog, self.base_context, command="daily") + + msg = self.base_context.result.message + assert ( + "not found" not in (msg.content or "").lower() + ), msg.content + + @test + async def help_menu_no_args(self) -> None: + _reset_guild_state() + await self.command(self.cog, self.base_context) + + msg = self.base_context.result.message + embeds = getattr(msg, "embeds", None) + embed = None + if isinstance(embeds, list) and embeds: + embed = embeds[-1] + elif isinstance(embeds, tuple) and embeds: + inner = embeds[0] + if isinstance(inner, list) and inner: + embed = inner[-1] + assert embed is not None, embeds + assert embed.title == "Help menu", embed.title + + @test + async def unknown_group_or_command(self) -> None: + _reset_guild_state() + await self.command(self.cog, self.base_context, group="notarealgroupname") + + assert ( + 'No command called "notarealgroupname" found.' + in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def group_help_paginator_with_mocked_walk_commands(self) -> None: + """Patch walk_commands so get_group_help builds pages without a full gateway/cog tree.""" + _reset_guild_state() + await KilluaGuild.new(self.base_guild.id) + + fakes = _fake_commands_for_actions_help_group() + orig_start = HelpPaginator.start + HelpPaginator.start = Paginator.start + self.base_context.timeout_view = False + + async def _press_next(ctx): + await press_paginator_button( + ctx.current_view, + "next", + context=ctx, + message=ctx.result.message, + ) + ctx.current_view.stop() + + prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _press_next + try: + with patch.object( + self.cog.client, + "walk_commands", + return_value=fakes, + ): + await self.command(self.cog, self.base_context, group="actions") + finally: + HelpPaginator.start = orig_start + self.base_context.respond_to_view = prev_rtv + + msg = self.base_context.result.message + embeds = getattr(msg, "embeds", None) + emb = None + if isinstance(embeds, list) and embeds: + emb = embeds[-1] + elif isinstance(embeds, tuple) and embeds: + inner = embeds[0] + if isinstance(inner, list) and inner: + emb = inner[-1] + assert emb is not None, embeds + assert "`hlp_beta`" in (emb.title or ""), emb.title + assert "Beta stub for help group formatting tests." in (emb.description or ""), ( + emb.description + ) + fp = embed_footer_page(emb) + assert fp is not None, emb.footer + assert fp == (2, 2), fp diff --git a/killua/tests/groups/image_manipulation.py b/killua/tests/groups/image_manipulation.py new file mode 100644 index 000000000..7f6d2fe5f --- /dev/null +++ b/killua/tests/groups/image_manipulation.py @@ -0,0 +1,266 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.image_manipulation import ImageManipulation + +from unittest.mock import AsyncMock +import io + + +class MockPxlResult: + def __init__(self, success=True, error="", file_type="png"): + self.success = success + self.file_type = file_type + self.error = error + + def convert_to_ioBytes(self): + return io.BytesIO(b"fake_image_data") + + +def _normalize_embeds(message): + e = getattr(message, "embeds", None) or [] + if isinstance(e, tuple) and e: + inner = e[0] + if isinstance(inner, list): + return inner + return list(e) + return list(e) if e else [] + + +def _assert_api_file(message, command_name: str, ext: str = "png"): + assert message.file is not None, "expected file on result message" + assert message.file.filename == f"{command_name}.{ext}", message.file.filename + + +class TestingImageManipulation(Testing): + requires_command = True + + def __init__(self): + super().__init__(cog=ImageManipulation) + + +class Ajit(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.ajit = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, target=None) + _assert_api_file(self.base_context.result.message, "ajit") + + +class Emojaic(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.emojaic = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, target=None) + _assert_api_file(self.base_context.result.message, "emojaic") + + +class Eyes(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.eyes = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, type="big", target=None) + _assert_api_file(self.base_context.result.message, "eyes") + + +class Flag(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.flag = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, flag="us", target=None) + _assert_api_file(self.base_context.result.message, "flag") + + +class Flash(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.flash = AsyncMock(return_value=MockPxlResult(file_type="gif")) + await self.command(self.cog, self.base_context, target=None) + assert self.base_context.result.message.file is not None + assert self.base_context.result.message.file.filename.endswith("flash.gif") + assert self.base_context.result.message.file.spoiler is True + + +class Glitch(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.glitch = AsyncMock(return_value=MockPxlResult(file_type="gif")) + await self.command(self.cog, self.base_context, target=None) + _assert_api_file(self.base_context.result.message, "glitch", ext="gif") + + +class Jpeg(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.jpeg = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, target=None) + _assert_api_file(self.base_context.result.message, "jpeg") + + @test + async def api_error_embed(self) -> None: + self.cog.pxl.jpeg = AsyncMock( + return_value=MockPxlResult(success=False, error="pxl down") + ) + await self.command(self.cog, self.base_context, target=None) + embeds = _normalize_embeds(self.base_context.result.message) + assert embeds, embeds + assert "error" in embeds[0].title.lower(), embeds[0].title + + +class Lego(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.lego = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, target=None) + _assert_api_file(self.base_context.result.message, "lego") + + +class Nokia(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.imagescript = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, target=None) + _assert_api_file(self.base_context.result.message, "nokia") + + +class Screenshot(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.screenshot = AsyncMock(return_value=MockPxlResult()) + await self.command( + self.cog, + self.base_context, + website="https://example.com/page", + ) + _assert_api_file(self.base_context.result.message, "screenshot") + + +class Snapchat(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.snapchat = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, filter="dog", target=None) + _assert_api_file(self.base_context.result.message, "snapchat") + + +class Sonic(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.sonic = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, text="gotta go fast") + _assert_api_file(self.base_context.result.message, "sonic") + + +class Spin(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + buf = io.BytesIO(b"gif-bytes") + self.cog._create_spin_gif = AsyncMock(return_value=buf) + await self.command(self.cog, self.base_context, target=None) + assert self.base_context.result.message.file is not None + assert self.base_context.result.message.file.filename == "spin.gif" + + +class Thonkify(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + self.cog.pxl.thonkify = AsyncMock(return_value=MockPxlResult()) + await self.command(self.cog, self.base_context, text="hello") + _assert_api_file(self.base_context.result.message, "thonkify") + + @test + async def api_error(self) -> None: + self.cog.pxl.thonkify = AsyncMock( + return_value=MockPxlResult(success=False, error="API down") + ) + await self.command(self.cog, self.base_context, text="hello") + embeds = _normalize_embeds(self.base_context.result.message) + assert embeds, embeds + assert ( + "error" in embeds[0].title.lower() + ), embeds[0].title + + +class Wtf(TestingImageManipulation): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def success(self) -> None: + buf = io.BytesIO(b"png-bytes") + self.cog.create_wtf_meme = AsyncMock(return_value=buf) + await self.command(self.cog, self.base_context, target=None) + assert self.base_context.result.message.file is not None + assert self.base_context.result.message.file.filename == "wtf.png" diff --git a/killua/tests/groups/moderation.py b/killua/tests/groups/moderation.py new file mode 100644 index 000000000..4d4aa3898 --- /dev/null +++ b/killua/tests/groups/moderation.py @@ -0,0 +1,373 @@ +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +from discord.ext import commands + +from ..types import * +from ..types.permissions import Permissions +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.moderation import Moderation +from ...utils.classes.guild import Guild as KilluaGuild +from ...static.constants import DB + + +def _reset_guild_state(): + KilluaGuild.cache.clear() + DB.guilds.db["guilds"] = [] + + +class TestingModeration(Testing): + requires_command = True + + def __init__(self): + super().__init__(cog=Moderation) + + +class Prefix(TestingModeration): + + def __init__(self): + super().__init__() + + @test + async def show_prefix(self) -> None: + _reset_guild_state() + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.content + == "The current server prefix is `k!`" + ), self.base_context.result.message.content + + @test + async def set_prefix_as_admin(self) -> None: + _reset_guild_state() + self.base_context.author.guild_permissions = Permissions(administrator=True) + + await self.command(self.cog, self.base_context, prefix="!!") + + assert ( + self.base_context.result.message.content + == "Successfully changed server prefix to `!!`!" + ), self.base_context.result.message.content + + @test + async def set_prefix_as_non_admin(self) -> None: + _reset_guild_state() + self.base_context.author.guild_permissions = Permissions(administrator=False) + + await self.command(self.cog, self.base_context, prefix="!!") + + assert ( + self.base_context.result.message.content + == "You need `administrator` permissions to change the server prefix!" + ), self.base_context.result.message.content + + +class Kick(TestingModeration): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def target_is_bot(self) -> None: + await self.command(self.cog, self.base_context, member=self.base_context.me) + + assert ( + self.base_context.result.message.content == "Hey!" + ), self.base_context.result.message.content + + @test + async def target_is_self(self) -> None: + await self.command(self.cog, self.base_context, member=self.base_context.author) + + assert ( + self.base_context.result.message.content + == "You can't kick yourself!" + ), self.base_context.result.message.content + + @test + async def kick_success_no_dm(self) -> None: + victim = DiscordMember( + id=999010, + bot=False, + username="Victim", + top_role=Role(position=0), + guild_permissions=Permissions(administrator=False, kick_members=False), + ) + self.base_author.top_role = Role(position=10) + self.base_context.me.top_role = Role(position=10) + victim.kick = AsyncMock() + + await self.command( + self.cog, + self.base_context, + member=victim, + config=0, + reason="spam", + ) + + victim.kick.assert_awaited_once() + content = self.base_context.result.message.content + assert ":hammer: Kicked **Victim**" in content + assert "spam" in content + assert "Operating moderator:" in content + + @test + async def kick_victim_higher_role_than_author(self) -> None: + victim = DiscordMember( + id=999011, + bot=False, + username="Boss", + top_role=Role(position=20), + ) + self.base_author.top_role = Role(position=10) + + await self.command(self.cog, self.base_context, member=victim, config=0) + + assert ( + "higher role than you" in self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Ban(TestingModeration): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def ban_member_success(self) -> None: + victim = DiscordMember( + id=999020, + bot=False, + username="BadActor", + top_role=Role(position=0), + ) + self.base_author.top_role = Role(position=10) + self.base_context.me.top_role = Role(position=10) + victim.ban = AsyncMock() + + with patch.object( + commands.MemberConverter, + "convert", + new=AsyncMock(return_value=victim), + ): + await self.command( + self.cog, + self.base_context, + member="BadActor", + config=0, + reason="ads", + ) + + victim.ban.assert_awaited_once() + content = self.base_context.result.message.content + assert ":hammer: Banned **BadActor**" in content + assert "ads" in content + + @test + async def ban_numeric_id_success(self) -> None: + uid = "912345678901234567" + self.base_context.guild.ban = AsyncMock() + + with patch.object( + commands.MemberConverter, + "convert", + new=AsyncMock(side_effect=commands.MemberNotFound(uid)), + ): + await self.command( + self.cog, + self.base_context, + member=uid, + reason="raid", + ) + + self.base_context.guild.ban.assert_awaited_once() + content = self.base_context.result.message.content + assert ":hammer: Banned **" in content + assert "raid" in content + + @test + async def ban_numeric_id_http_error(self) -> None: + uid = "912345678901234568" + resp = MagicMock() + resp.status = 400 + + async def boom(*a, **k): + raise discord.HTTPException(resp, {"code": 500, "message": "fail"}) + + self.base_context.guild.ban = boom + + with patch.object( + commands.MemberConverter, + "convert", + new=AsyncMock(side_effect=commands.MemberNotFound(uid)), + ): + await self.command(self.cog, self.base_context, member=uid) + + assert ( + "Something went wrong" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def ban_member_higher_role_than_author(self) -> None: + victim = DiscordMember( + id=999021, + bot=False, + username="ModPlus", + top_role=Role(position=50), + ) + self.base_author.top_role = Role(position=10) + + with patch.object( + commands.MemberConverter, + "convert", + new=AsyncMock(return_value=victim), + ): + await self.command( + self.cog, self.base_context, member="x", config=0 + ) + + assert "higher role than you" in self.base_context.result.message.content + + +class Unban(TestingModeration): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def unban_digit_success(self) -> None: + self.base_context.guild.unban = AsyncMock() + await self.command(self.cog, self.base_context, member="888877776666555444") + + self.base_context.guild.unban.assert_awaited_once() + assert "Unbanned user with id" in self.base_context.result.message.content + + @test + async def unban_digit_http_10013(self) -> None: + resp = MagicMock() + resp.status = 404 + + async def unban_fail(*a, **k): + raise discord.HTTPException( + resp, {"code": 10013, "message": "Unknown User"} + ) + + self.base_context.guild.unban = unban_fail + + await self.command(self.cog, self.base_context, member="111122223333444455") + + assert ( + "No user with the user ID" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def unban_digit_http_10026(self) -> None: + resp = MagicMock() + resp.status = 400 + + async def unban_fail(*a, **k): + raise discord.HTTPException( + resp, {"code": 10026, "message": "not banned"} + ) + + self.base_context.guild.unban = unban_fail + + await self.command(self.cog, self.base_context, member="222233334444555566") + + assert ( + self.base_context.result.message.content + == "The user is not currently banned" + ), self.base_context.result.message.content + + @test + async def unban_by_username(self) -> None: + banned_user = DiscordUser(name="exactname", id=333001, username="u1") + entry = type("BanEntry", (), {"user": banned_user})() + self.base_context.guild._ban_list = [entry] + self.base_context.guild.unban = AsyncMock() + + await self.command(self.cog, self.base_context, member="exactname") + + self.base_context.guild.unban.assert_awaited_once_with(entry) + assert "Unbanned <@333001>" in self.base_context.result.message.content + + +class Shush(TestingModeration): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def shush_success(self) -> None: + victim = DiscordMember( + id=999030, + bot=False, + username="Loud", + top_role=Role(position=0), + ) + self.base_author.top_role = Role(position=10) + self.base_context.me.top_role = Role(position=10) + victim.timeout = AsyncMock() + + await self.command( + self.cog, + self.base_context, + member=victim, + time=timedelta(seconds=60), + reason="noise", + ) + + victim.timeout.assert_awaited_once() + args, kwargs = victim.timeout.await_args + assert args[0] == timedelta(seconds=60) + content = self.base_context.result.message.content + assert "Timeouted" in content + assert "noise" in content + + +class Unshush(TestingModeration): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def unshush_not_timed_out(self) -> None: + victim = DiscordMember( + id=999040, + bot=False, + timed_out=False, + top_role=Role(position=0), + ) + self.base_author.top_role = Role(position=10) + + await self.command(self.cog, self.base_context, member=victim) + + assert ( + self.base_context.result.message.content + == "This user is not timed out" + ), self.base_context.result.message.content + + @test + async def unshush_success(self) -> None: + victim = DiscordMember( + id=999041, + bot=False, + timed_out=True, + top_role=Role(position=0), + ) + self.base_author.top_role = Role(position=10) + self.base_context.me.top_role = Role(position=10) + victim.timeout = AsyncMock() + + await self.command(self.cog, self.base_context, member=victim, reason="ok") + + victim.timeout.assert_awaited_with(None, reason="ok") + assert "Removed the timeout" in self.base_context.result.message.content diff --git a/killua/tests/groups/premium.py b/killua/tests/groups/premium.py new file mode 100644 index 000000000..016932120 --- /dev/null +++ b/killua/tests/groups/premium.py @@ -0,0 +1,222 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.premium import Premium +from ...utils.classes.guild import Guild as KilluaGuild +from ...static.constants import DB, LOOTBOXES, PATREON_TIERS + +from datetime import datetime, timedelta + + +def _reset_guild_state(): + KilluaGuild.cache.clear() + DB.guilds.db["guilds"] = [] + + +class TestingPremium(Testing): + requires_command = True + + def __init__(self): + super().__init__(cog=Premium) + + +class Info(TestingPremium): + + def __init__(self): + super().__init__() + + @test + async def sends_embed(self) -> None: + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + self.base_context.result.message.embeds[0].title == "**Support Killua**" + ), self.base_context.result.message.embeds[0].title + + +class Guild(TestingPremium): + + def __init__(self): + super().__init__() + + @test + async def add_not_premium(self) -> None: + _reset_guild_state() + uid = self.base_author.id + await User.new(uid) + await DB.teams.update_one( + {"id": uid}, {"$set": {"badges": [], "premium_guilds": {}}} + ) + User.cache.pop(uid, None) + + await self.command(self.cog, self.base_context, action="add") + + assert ( + "Sadly you aren't a premium subscriber" + in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def remove_not_premium_guild(self) -> None: + _reset_guild_state() + + await self.command(self.cog, self.base_context, action="remove") + + assert ( + "This guild is not a premium guild" + in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def add_success(self) -> None: + _reset_guild_state() + uid = self.base_author.id + gid = self.base_guild.id + tier = next(iter(PATREON_TIERS.keys())) + await User.new(uid) + await DB.teams.update_one( + {"id": uid}, + {"$set": {"badges": [tier], "premium_guilds": {}}}, + ) + User.cache.pop(uid, None) + + await self.command(self.cog, self.base_context, action="add") + + assert ( + "Success" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def add_guild_already_premium(self) -> None: + _reset_guild_state() + uid = self.base_author.id + gid = self.base_guild.id + tier = next(iter(PATREON_TIERS.keys())) + await User.new(uid) + await DB.teams.update_one( + {"id": uid}, + {"$set": {"badges": [tier], "premium_guilds": {}}}, + ) + User.cache.pop(uid, None) + g = await KilluaGuild.new(gid) + await g.add_premium() + + await self.command(self.cog, self.base_context, action="add") + + assert ( + "already has the premium" in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + @test + async def add_premium_guild_slots_full(self) -> None: + _reset_guild_state() + uid = self.base_author.id + gid = self.base_guild.id + tier = next(iter(PATREON_TIERS.keys())) + other_gid = gid + 424242 + await User.new(uid) + await DB.teams.update_one( + {"id": uid}, + { + "$set": { + "badges": [tier], + "premium_guilds": {str(other_gid): datetime.now()}, + } + }, + ) + User.cache.pop(uid, None) + + await self.command(self.cog, self.base_context, action="add") + + assert ( + "remove premium perks from one of your other servers" + in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + @test + async def remove_success_after_cooldown(self) -> None: + _reset_guild_state() + uid = self.base_author.id + gid = self.base_guild.id + tier = next(iter(PATREON_TIERS.keys())) + await User.new(uid) + g = await KilluaGuild.new(gid) + await g.add_premium() + old = datetime.now() - timedelta(days=10) + await DB.teams.update_one( + {"id": uid}, + { + "$set": { + "badges": [tier], + "premium_guilds": {str(gid): old}, + } + }, + ) + User.cache.pop(uid, None) + + await self.command(self.cog, self.base_context, action="remove") + + assert ( + "Successfully removed" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def remove_wrong_subscriber(self) -> None: + _reset_guild_state() + uid = self.base_author.id + gid = self.base_guild.id + tier = next(iter(PATREON_TIERS.keys())) + await User.new(uid) + g = await KilluaGuild.new(gid) + await g.add_premium() + await DB.teams.update_one( + {"id": uid}, + { + "$set": { + "badges": [tier], + "premium_guilds": {}, + } + }, + ) + User.cache.pop(uid, None) + + await self.command(self.cog, self.base_context, action="remove") + + assert ( + "not the one who added" in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + +class Weekly(TestingPremium): + + def __init__(self): + super().__init__() + + @test + async def cooldown_active(self) -> None: + user = await User.new(self.base_author.id) + user.weekly_cooldown = datetime.now() + timedelta(days=3) + await user._update_val("weekly_cooldown", user.weekly_cooldown) + + await self.command(self.cog, self.base_context) + + assert ( + "You can claim your weekly lootbox the next time" + in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def claim_available(self) -> None: + user = await User.new(self.base_author.id) + user.weekly_cooldown = None + await user._update_val("weekly_cooldown", None) + + await self.command(self.cog, self.base_context) + + assert ( + "Successfully claimed lootbox" + in self.base_context.result.message.content + ), self.base_context.result.message.content diff --git a/killua/tests/groups/prometheus_cov.py b/killua/tests/groups/prometheus_cov.py new file mode 100644 index 000000000..434558564 --- /dev/null +++ b/killua/tests/groups/prometheus_cov.py @@ -0,0 +1,104 @@ +"""Prometheus cog metric paths with mocks (no HTTP server).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from ..testing import Testing, test, collect_test_classes +from ..types import Bot +from ...cogs.prometheus import PrometheusCog +from ...static.constants import API_ROUTES + + +class TestingPrometheus(Testing): + def __init__(self) -> None: + super().__init__(cog=PrometheusCog) + + @property + def all_tests(self): + return collect_test_classes(self.__class__) + + +class _PromTests(TestingPrometheus): + requires_command = False + + +class PrometheusHandlerTests(_PromTests): + @test + async def update_api_stats(self) -> None: + cog = PrometheusCog(Bot, port=9999) + route = next(iter(API_ROUTES)) + payload = { + "ipc": {"response_time": 12}, + "usage": { + route: { + "request_count": 10, + "successful_responses": 9, + "failed_responses": 1, + }, + "spam": {"request_count": 0}, + }, + } + + class Resp: + status = 200 + headers = {"X-Response-Time": "5ms"} + + async def json(self): + return payload + + Bot.session.get = AsyncMock(return_value=Resp()) + await cog.update_api_stats() + assert cog.api_previous.get(route, {}).get("request_count") == 10 + + @test + async def update_api_stats_non_200(self) -> None: + cog = PrometheusCog(Bot, port=9999) + + class Resp: + status = 500 + + Bot.session.get = AsyncMock(return_value=Resp()) + await cog.update_api_stats() + + @test + async def on_connect_and_disconnect(self) -> None: + cog = PrometheusCog(Bot, port=9999) + await cog.on_connect() + await cog.on_disconnect() + await cog.on_shard_ready(0) + await cog.on_shard_disconnect(0) + + @test + async def on_ready_starts_server_in_docker(self) -> None: + cog = PrometheusCog(Bot, port=9999) + Bot.run_in_docker = True + try: + with patch.object(cog, "init_gauges", AsyncMock()): + with patch.object(cog, "start_prometheus") as start: + await cog.on_ready() + await cog.on_ready() + start.assert_called_once() + finally: + Bot.run_in_docker = False + cog.initial = False + + @test + async def on_command_increments(self) -> None: + cog = PrometheusCog(Bot, port=9999) + ctx = self.base_context + cmd = Bot.get_command("ping") + if cmd is None: + + class _PingCmd: + name = "ping" + qualified_name = "ping" + extras = {"id": 1} + cog = None + + cmd = _PingCmd() + else: + cmd.extras = dict(getattr(cmd, "extras", None) or {}) + cmd.extras["id"] = 1 + ctx.command = cmd + await cog.on_command(ctx) diff --git a/killua/tests/groups/shop.py b/killua/tests/groups/shop.py new file mode 100644 index 000000000..0428b19b4 --- /dev/null +++ b/killua/tests/groups/shop.py @@ -0,0 +1,449 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...static.constants import editing, DB +from ...cogs.shop import Shop +from ...static.constants import LOOTBOXES, PRICES +from ..types.member import TestingMember +from ...static.cards import Card + +import copy +from unittest.mock import patch, AsyncMock + +from ..harnesses import embed_footer_page, press_paginator_button + +from pathlib import Path +from datetime import datetime +import json + + +def _ensure_card_catalog() -> None: + if Card.raw: + return + cards_file = Path(__file__).parents[3] / "cards.json" + if cards_file.exists(): + with open(cards_file) as f: + Card.raw = json.load(f) + + +class TestingShop(Testing): + requires_command = True + + def __init__(self): + _ensure_card_catalog() + super().__init__(cog=Shop) + self.base_context.command = self.command + + async def _ensure_shop_const(self) -> None: + sh = await DB.const.find_one({"_id": "shop"}) + _seed_shop_offers_sync((sh or {}).get("offers", [])) + + +class Jenny(TestingShop): + + def __init__(self): + super().__init__() + + @test + async def give_to_self(self) -> None: + other = TestingMember(id=self.base_author.id) + await self.command(self.cog, self.base_context, other, amount=10) + assert ( + self.base_context.result.message.content + == "You can't give yourself anything!" + ), self.base_context.result.message.content + + @test + async def give_to_bot(self) -> None: + other = TestingMember(bot=True) + await self.command(self.cog, self.base_context, other, amount=10) + assert ( + self.base_context.result.message.content == "\U0001f916" + ), self.base_context.result.message.content + + @test + async def less_than_one(self) -> None: + other = TestingMember() + await self.command(self.cog, self.base_context, other, amount=0) + assert ( + self.base_context.result.message.content + == "You can't transfer less than 1 Jenny!" + ), self.base_context.result.message.content + + @test + async def not_enough_jenny(self) -> None: + user = await User.new(self.base_author.id) + await user.remove_jenny(user.jenny) + other = TestingMember() + await self.command(self.cog, self.base_context, other, amount=100) + assert ( + self.base_context.result.message.content + == "You can't transfer more Jenny than you have" + ), self.base_context.result.message.content + + @test + async def success(self) -> None: + user = await User.new(self.base_author.id) + await user.add_jenny(100) + other = TestingMember() + await self.command(self.cog, self.base_context, other, amount=10) + assert ( + "transferred" in self.base_context.result.message.content + and "Jenny" in self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Lootbox(TestingShop): + + def __init__(self): + super().__init__() + + @test + async def invalid_box(self) -> None: + await self.command(self.cog, self.base_context, box="999") + assert ( + self.base_context.result.message.content + == "This lootbox is not for sale!" + ), self.base_context.result.message.content + + @test + async def not_enough_jenny(self) -> None: + first_box_id = next(k for k, v in LOOTBOXES.items() if v["available"]) + user = await User.new(self.base_author.id) + await user.remove_jenny(user.jenny) + await self.command(self.cog, self.base_context, box=str(first_box_id)) + assert ( + "don't have enough jenny" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def success(self) -> None: + first_box_id = next(k for k, v in LOOTBOXES.items() if v["available"]) + price = LOOTBOXES[first_box_id]["price"] + user = await User.new(self.base_author.id) + await user.add_jenny(price) + await self.command(self.cog, self.base_context, box=str(first_box_id)) + assert ( + "Successfully bought lootbox" in self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Lootboxes(TestingShop): + + def __init__(self): + super().__init__() + + @test + async def shop_paginator_next_page(self) -> None: + """Path A: shop lootboxes uses ShopPaginator when >10 boxes; patch start to avoid menu re-invoke.""" + from ...cogs.shop import ShopPaginator + from ...utils.paginator import Paginator + + extra = {} + for i in range(10001, 10013): + d = copy.deepcopy(LOOTBOXES[1]) + d["name"] = f"Test paginator box {i}" + d["available"] = True + extra[i] = d + orig_start = ShopPaginator.start + ShopPaginator.start = Paginator.start + self.base_context.timeout_view = False + + async def _next(ctx): + await press_paginator_button( + ctx.current_view, + "next", + context=ctx, + message=ctx.result.message, + ) + ctx.current_view.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _next + try: + LOOTBOXES.update(extra) + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context) + finally: + for k in extra: + LOOTBOXES.pop(k, None) + ShopPaginator.start = orig_start + self.base_context.respond_to_view = _prev_rtv + + msg = self.base_context.result.message + embeds = getattr(msg, "embeds", None) + emb = None + if isinstance(embeds, list) and embeds: + emb = embeds[-1] + elif isinstance(embeds, tuple) and embeds: + inner = embeds[0] + if isinstance(inner, list) and inner: + emb = inner[-1] + assert emb is not None, embeds + fp = embed_footer_page(emb) + assert fp is not None, emb.footer + assert fp[0] == 2 and fp[1] >= 2, fp + + +def _seed_shop_offers_sync(card_ids: list) -> None: + """Mutate shared test DB.const collection (same store as DB.const property).""" + coll = DB.const.db.setdefault("const", []) + for doc in coll: + if doc.get("_id") == "shop": + doc["offers"] = card_ids + doc["reduced"] = None + doc.setdefault("log", []) + return + coll.append({"_id": "shop", "offers": card_ids, "reduced": None, "log": []}) + + +async def _seed_shop_offers(card_ids: list) -> None: + _seed_shop_offers_sync(card_ids) + + +class ShopCmd(TestingShop): + command_name = "shop" + + def __init__(self): + super().__init__() + + @test + async def main_shop_embed(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.embeds + + @test + async def shop_select_invokes_subcommand(self) -> None: + """Top-level shop: select menu invokes the chosen subcommand.""" + from killua.utils.interactions import Select as KSelect + + subcommands = list(self.command.commands) + target = next(c for c in subcommands if c.name == "cards") + + self.base_context.timeout_view = False + _prev_invoke = self.base_context.invoke + invoke_mock = AsyncMock(wraps=_prev_invoke) + self.base_context.invoke = invoke_mock + + async def pick_cards_shop(ctx): + select = None + for child in ctx.current_view.children: + if isinstance(child, KSelect): + select = child + break + assert select is not None + idx = str(subcommands.index(target)) + await select.callback( + ArgumentInteraction(ctx, data={"values": [idx]}) + ) + ctx.current_view.stop() + + _prev = self.base_context.respond_to_view + self.base_context.respond_to_view = pick_cards_shop + try: + await self.command(self.cog, self.base_context) + finally: + self.base_context.respond_to_view = _prev + self.base_context.invoke = _prev_invoke + + invoke_mock.assert_awaited_once() + assert invoke_mock.await_args[0][0].name == "cards" + + +class CardsShopCmd(TestingShop): + command_name = "cards" + + def __init__(self): + super().__init__() + + @test + async def cards_shop_embed(self) -> None: + await self._ensure_shop_const() + self.cog.last_update = datetime.now() + self.base_context.timeout_view = True + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.embeds + + +class TodoShopCmd(TestingShop): + command_name = "todo" + + def __init__(self): + super().__init__() + + @test + async def todo_shop_embed(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.embeds + + +class BuyCmd(TestingShop): + command_name = "buy" + + def __init__(self): + super().__init__() + + @test + async def buy_without_item(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.content + + def _buy_subcommand(self, name: str): + from discord.ext.commands import Command + + for command in self.cog.walk_commands(): + if ( + isinstance(command, Command) + and command.name == name + and getattr(command.parent, "name", None) == "buy" + ): + return command + raise AssertionError(f"buy {name} subcommand not found") + + @test + async def buy_space_insufficient_jenny(self) -> None: + from ...utils.classes.todo import TodoList + + todo_list = await TodoList.create( + owner=self.base_author.id, + title="Poor list", + status="public", + done_delete=False, + ) + editing[self.base_author.id] = todo_list.id + user = await User.new(self.base_author.id) + await user.set_jenny(0) + + await self._buy_subcommand("todo")(self.cog, self.base_context, what="space") + + assert "don't have enough Jenny" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + assert user.jenny == 0 + + @test + async def buy_space_cancel(self) -> None: + from ...utils.classes.todo import TodoList + + todo_list = await TodoList.create( + owner=self.base_author.id, + title="Cancel list", + status="public", + done_delete=False, + ) + editing[self.base_author.id] = todo_list.id + user = await User.new(self.base_author.id) + await user.add_jenny(50_000) + spots_before = todo_list.spots + + self.base_context.timeout_view = False + self.base_context.respond_to_view = Testing.press_cancel + + await self._buy_subcommand("todo")(self.cog, self.base_context, what="space") + + assert "see you later" in ( + self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + refreshed = await TodoList.new(todo_list.id) + assert refreshed.spots == spots_before + + @test + async def buy_lootbox_insufficient_jenny_via_buy(self) -> None: + first_box_id = next(k for k, v in LOOTBOXES.items() if v["available"]) + user = await User.new(self.base_author.id) + await user.set_jenny(0) + + await self._buy_subcommand("lootbox")( + self.cog, self.base_context, box=str(first_box_id) + ) + + assert "don't have enough jenny" in ( + self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + @test + async def buy_lootbox_success_deducts_jenny(self) -> None: + first_box_id = next(k for k, v in LOOTBOXES.items() if v["available"]) + price = LOOTBOXES[first_box_id]["price"] + user = await User.new(self.base_author.id) + await user.set_jenny(price + 100) + before = user.jenny + + await self._buy_subcommand("lootbox")( + self.cog, self.base_context, box=str(first_box_id) + ) + + assert "Successfully bought lootbox" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + user = await User.new(self.base_author.id) + assert user.jenny == before - price + + @test + async def buy_card_insufficient_jenny(self) -> None: + card_id = 572 + await _seed_shop_offers([card_id]) + user = await User.new(self.base_author.id) + await user.set_jenny(0) + price = PRICES[Card(card_id).rank] + + await self._buy_subcommand("card")(self.cog, self.base_context, item=str(card_id)) + + assert "don't have enough Jenny" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + assert user.jenny == 0 + user = await User.new(self.base_author.id) + assert not user.has_any_card(card_id) + + @test + async def buy_card_success_deducts_jenny(self) -> None: + card_id = 572 + await _seed_shop_offers([card_id]) + user = await User.new(self.base_author.id) + price = PRICES[Card(card_id).rank] + await user.add_jenny(price + 50) + before = user.jenny + + await self._buy_subcommand("card")(self.cog, self.base_context, item=str(card_id)) + + assert "Successfully bought card" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + user = await User.new(self.base_author.id) + assert user.jenny == before - price + assert user.has_any_card(card_id) + + @test + async def buy_space_confirm_adds_spots(self) -> None: + """One ConfirmButton on ctx.send — press confirm via respond_to_view.""" + from ...utils.classes.todo import TodoList + + todo_list = await TodoList.create( + owner=self.base_author.id, + title="Shop list", + status="public", + done_delete=False, + ) + editing[self.base_author.id] = todo_list.id + user = await User.new(self.base_author.id) + await user.add_jenny(50_000) + jenny_before = user.jenny + spots_before = todo_list.spots + cost = int(100 * todo_list.spots * 0.5) + + self.base_context.timeout_view = False + self.base_context.respond_to_view = Testing.press_confirm + + await self._buy_subcommand("todo")( + self.cog, self.base_context, what="space" + ) + + assert "bought 10 more todo spots" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + refreshed = await TodoList.new(todo_list.id) + assert refreshed.spots == spots_before + 10, refreshed.spots + user = await User.new(self.base_author.id) + assert user.jenny == jenny_before - cost + diff --git a/killua/tests/groups/small_commands.py b/killua/tests/groups/small_commands.py new file mode 100644 index 000000000..fcfd3b25c --- /dev/null +++ b/killua/tests/groups/small_commands.py @@ -0,0 +1,486 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.small_commands import SmallCommands + +from unittest.mock import MagicMock, AsyncMock, patch + +import discord + + +def _embed0(message): + raw = message.embeds + if isinstance(raw, list) and raw: + return raw[0] + if isinstance(raw, tuple) and raw: + inner = raw[0] + if isinstance(inner, list) and inner: + return inner[0] + return raw[0] if raw else None + + +from ..harnesses import ListenerFakeButton, ListenerFakeRow + +# Backwards-compatible aliases for listener-style tests in this module. +_ListenerFakeButton = ListenerFakeButton +_ListenerFakeRow = ListenerFakeRow + + +class TestingSmallCommands(Testing): + requires_command = True + _menus_registered = False + + def __init__(self): + if not TestingSmallCommands._menus_registered: + TestingSmallCommands._menus_registered = True + else: + SmallCommands._init_menus = lambda self: None + super().__init__(cog=SmallCommands) + + +class Uwufy(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def transforms_text(self) -> None: + await self.command(self.cog, self.base_context, text="Hello world this is a test") + assert self.base_context.result.message.content, self.base_context.result.message.content + + @test + async def preserves_non_empty_output(self) -> None: + await self.command(self.cog, self.base_context, text="test") + assert len(self.base_context.result.message.content) > 0 + + +class Ping(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def responds_pong(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.content.startswith("Pong"), self.base_context.result.message.content + assert "ms" in self.base_context.result.message.content, self.base_context.result.message.content + + +class Topic(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def sends_topic(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.content, self.base_context.result.message.content + assert len(self.base_context.result.message.content) > 10 + + +class Hi(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def greets_author(self) -> None: + await self.command(self.cog, self.base_context) + expected = "Hello " + str(self.base_context.author) + assert self.base_context.result.message.content == expected, self.base_context.result.message.content + + +class Invite(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def sends_invite_embed(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.embeds, self.base_context.result.message.embeds + assert self.base_context.result.message.embeds[0].title == "Invite", self.base_context.result.message.embeds[0].title + + +class Vote(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def sends_vote_message(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.content.startswith("Thanks for supporting"), self.base_context.result.message.content + + +class Permissions(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + self.base_context.me.guild_permissions = [ + ("send_messages", True), + ("administrator", False), + ] + + @test + async def sends_permissions_embed(self) -> None: + await self.command(self.cog, self.base_context) + assert self.base_context.result.message.embeds, self.base_context.result.message.embeds + assert self.base_context.result.message.embeds[0].title == "Bot permissions", self.base_context.result.message.embeds[0].title + + +class EightBall(TestingSmallCommands): + """discord.py command name is ``8ball`` (invalid Python identifier).""" + + command_name = "8ball" + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def responds_with_embed(self) -> None: + await self.command(self.cog, self.base_context, question="Will it work?") + emb = _embed0(self.base_context.result.message) + assert emb is not None + assert "8ball" in emb.title.lower(), emb.title + assert "Will it work" in emb.description, emb.description + + +class Avatar(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def default_shows_author(self) -> None: + await self.command(self.cog, self.base_context, user=None, guild_avatar="no") + emb = _embed0(self.base_context.result.message) + assert emb.title.startswith("Avatar of"), emb.title + + @test + async def user_without_avatar(self) -> None: + u = MagicMock() + u.display_name = "NoAvatar" + u.avatar = None + u.display_avatar = None + await self.command(self.cog, self.base_context, user=u, guild_avatar="no") + assert ( + self.base_context.result.message.content == "User has no avatar" + ), self.base_context.result.message.content + + +class Translate(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def invalid_language(self) -> None: + await self.command( + self.cog, + self.base_context, + source="notareallanguage", + target="english", + text="hello", + ) + assert ( + "Invalid language" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def success_with_mocked_api(self) -> None: + orig_get = self.cog.client.session.get + + class Resp: + status = 200 + + async def json(self): + return { + "responseData": {"translatedText": "bonjour"}, + "matches": [{"quality": 90}], + } + + async def text(self): + return "" + + self.cog.client.session.get = AsyncMock(return_value=Resp()) + await self.command( + self.cog, + self.base_context, + source="english", + target="french", + text="hello", + ) + self.cog.client.session.get = orig_get + emb = _embed0(self.base_context.result.message) + assert emb is not None + assert emb.title == "Translation Successful", emb.title + assert "bonjour" in emb.description, emb.description + + @test + async def api_non_200(self) -> None: + orig_get = self.cog.client.session.get + + class Resp: + status = 503 + + async def json(self): + return {} + + async def text(self): + return "unavailable" + + self.cog.client.session.get = AsyncMock(return_value=Resp()) + await self.command( + self.cog, + self.base_context, + source="english", + target="french", + text="hello", + ) + self.cog.client.session.get = orig_get + assert ":x:" in self.base_context.result.message.content + + +class Calc(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def missing_expression(self) -> None: + await self.command(self.cog, self.base_context, expression=None) + assert ( + "Please give me something" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def success_mocked_mathjs(self) -> None: + orig_post = self.cog.client.session.post + + class Resp: + status = 200 + + async def json(self): + return {"result": ["42"], "error": None} + + self.cog.client.session.post = AsyncMock(return_value=Resp()) + await self.command(self.cog, self.base_context, expression="6*7") + self.cog.client.session.post = orig_post + assert "42" in self.base_context.result.message.content + + @test + async def mathjs_reports_error(self) -> None: + orig_post = self.cog.client.session.post + + class Resp: + status = 200 + + async def json(self): + return {"result": None, "error": "Unexpected operator"} + + self.cog.client.session.post = AsyncMock(return_value=Resp()) + await self.command(self.cog, self.base_context, expression="+++") + self.cog.client.session.post = orig_post + assert ( + "Unexpected operator" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def malformed_json_response(self) -> None: + orig_post = self.cog.client.session.post + + class Resp: + status = 200 + + async def json(self): + return {"oops": True} + + self.cog.client.session.post = AsyncMock(return_value=Resp()) + await self.command(self.cog, self.base_context, expression="1+1") + self.cog.client.session.post = orig_post + assert ( + "unknown error" in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + +class Wyr(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def sends_question_embed(self) -> None: + await self.command(self.cog, self.base_context) + emb = _embed0(self.base_context.result.message) + assert emb is not None + assert "would you rather" in emb.title.lower(), emb.title + assert len(emb.fields) >= 2, emb.fields + + @test + async def vote_option_a_updates_embed(self) -> None: + """Path B: Events.on_interaction for wyr:opt-a (see killua/tests/component_interaction.py).""" + from ...cogs.events import Events + from ...utils.classes.guild import Guild as KilluaGuild + from ..harnesses import MockComponentInteraction + + await KilluaGuild.new(self.base_guild.id) + voter = DiscordMember(guild=self.base_guild, id=self.base_author.id + 8000) + self.base_guild.members = [self.base_author, voter] + + emb = discord.Embed(title="Would you rather...", color=0x3E4A78) + emb.add_field(name="A) left `[0 people]`", value="No takers", inline=False) + emb.add_field(name="B) right `[0 people]`", value="No takers", inline=False) + sty = int(discord.ButtonStyle.blurple) + row = _ListenerFakeRow( + [ + _ListenerFakeButton(custom_id="wyr:opt-a:", label="A", style=sty), + _ListenerFakeButton(custom_id="wyr:opt-b:", label="B", style=sty), + ] + ) + + class PM: + def __init__(self): + self.id = 777001 + self.embeds = [emb] + self.components = [row] + + pm = PM() + events = Events(self.cog.client) + with patch("killua.bot.randint", return_value=100): + await events.on_interaction( + MockComponentInteraction( + context=self.base_context, + custom_id="wyr:opt-a:", + user=voter, + message=pm, + client=self.cog.client, + ) + ) + name0 = pm.embeds[0].fields[0].name + assert "1 person" in name0 or "1 people" in name0, name0 + + +class Poll(TestingSmallCommands): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def vote_first_option_updates_embed(self) -> None: + """Path B: Events.on_interaction for poll:opt-1 completes a component response.""" + from ...cogs.events import Events + from ...utils.classes.guild import Guild as KilluaGuild + from ..harnesses import MockComponentInteraction + + await KilluaGuild.new(self.base_guild.id) + voter = DiscordMember(guild=self.base_guild, id=self.base_author.id + 9000) + self.base_guild.members = [self.base_author, voter] + + emb = discord.Embed(title="Poll", description="Q?", color=0x3E4A78) + emb.add_field(name="1) One `[0 votes]`", value="No votes", inline=False) + emb.add_field(name="2) Two `[0 votes]`", value="No votes", inline=False) + enc = self.cog.client._encrypt(self.base_author.id, smallest=False) + sty = int(discord.ButtonStyle.blurple) + red = int(discord.ButtonStyle.red) + row = _ListenerFakeRow( + [ + _ListenerFakeButton(custom_id="poll:opt-1:", label="1", style=sty), + _ListenerFakeButton(custom_id="poll:opt-2:", label="2", style=sty), + _ListenerFakeButton( + custom_id=f"poll:close:{enc}:", + label="Close", + style=red, + ), + ] + ) + + class PM: + def __init__(self): + self.id = 777002 + self.embeds = [emb] + self.components = [row] + + pm = PM() + events = Events(self.cog.client) + ix = MockComponentInteraction( + context=self.base_context, + custom_id="poll:opt-1:", + user=voter, + message=pm, + client=self.cog.client, + ) + with patch("killua.bot.randint", return_value=100): + await events.on_interaction(ix) + assert ix.response.is_done(), ( + "poll vote path should respond via interaction.response (edit or send)" + ) + + @test + async def poll_modal_submit_publishes_embed(self) -> None: + """Hybrid poll path: interaction → modal → channel embed with option buttons.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + + class _ModalResp: + async def send_modal(self, modal): + self._modal = modal + + ix = MagicMock() + ix.response = _ModalResp() + self.base_context.interaction = ix + + def _filled_poll_setup(*_a, **_k): + modal = SimpleNamespace() + modal.timed_out = False + modal.children = [ + SimpleNamespace( + custom_id="question", + label="Question", + value="Doors or wheels?", + ), + SimpleNamespace( + custom_id="option:1", label="Option 1", value="Doors" + ), + SimpleNamespace( + custom_id="option:2", label="Option 2", value="Wheels" + ), + SimpleNamespace( + custom_id="option:3", label="Option 3", value="" + ), + SimpleNamespace( + custom_id="option:4", label="Option 4", value="" + ), + ] + + async def _wait(): + return False + + modal.wait = _wait + return modal + + with patch( + "killua.cogs.small_commands.PollSetup", side_effect=_filled_poll_setup + ): + await self.command(self.cog, self.base_context) + + emb = _embed0(self.base_context.result.message) + assert emb is not None, self.base_context.result.message.embeds + assert emb.title == "Poll", emb.title + assert "Doors or wheels" in (emb.description or ""), emb.description + view = self.base_context.current_view + assert view is not None, "poll should attach a view with option buttons" + assert len(getattr(view, "children", [])) >= 2, view.children diff --git a/killua/tests/groups/tags.py b/killua/tests/groups/tags.py new file mode 100644 index 000000000..83454f300 --- /dev/null +++ b/killua/tests/groups/tags.py @@ -0,0 +1,363 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.tags import Tags, Tag +from ...utils.classes.guild import Guild as KilluaGuild +from ...utils.test_db import TestingDatabase +from ..types.permissions import Permissions + +from datetime import datetime +from unittest.mock import patch + +from ..harnesses import ( + assert_embed_title, + embed_at, + embed_footer_page, + press_paginator_button, +) + + +class TestingTags(Testing): + requires_command = True + + _menus_registered = False + + def __init__(self): + if not TestingTags._menus_registered: + TestingTags._menus_registered = True + else: + Tags._init_menus = lambda self: None + super().__init__(cog=Tags) + self.base_channel._has_permission = Permissions(manage_guild=True) + + def _seed_guild(self, tags=None): + """Seed KilluaGuild cache and mock DB. tags=None omits the key from the DB doc.""" + guild_id = self.base_guild.id + tag_list = tags if tags is not None else [] + + KilluaGuild.cache.pop(guild_id, None) + KilluaGuild.cache[guild_id] = KilluaGuild( + id=guild_id, prefix="k!", tags=tag_list + ) + + coll = "guilds" + TestingDatabase.db.setdefault(coll, []) + TestingDatabase.db[coll] = [ + d for d in TestingDatabase.db[coll] if d.get("id") != guild_id + ] + doc = { + "_id": f"guild_{guild_id}", + "id": guild_id, + "prefix": "k!", + "badges": [], + "approximate_member_count": self.base_guild.member_count, + } + if tags is not None: + doc["tags"] = tag_list + TestingDatabase.db[coll].append(doc) + + def _make_tag(self, name="test", content="test content", owner=None, uses=0): + return { + "name": name, + "content": content, + "owner": owner or self.base_author.id, + "created_at": datetime.now(), + "uses": uses, + } + + +class Get(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def tag_not_found(self) -> None: + self._seed_guild(tags=[]) + await self.command(self.cog, self.base_context, name="nonexistent") + assert ( + self.base_context.result.message.content == "This tag doesn't exist" + ), self.base_context.result.message.content + + @test + async def tag_found(self) -> None: + tag_data = self._make_tag(name="hello", content="world") + self._seed_guild(tags=[tag_data]) + await self.command(self.cog, self.base_context, name="hello") + assert ( + self.base_context.result.message.content == "world" + ), self.base_context.result.message.content + + +class Delete(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def tag_not_found(self) -> None: + self._seed_guild(tags=[]) + await self.command(self.cog, self.base_context, name="nonexistent") + assert ( + self.base_context.result.message.content + == "A tag with that name does not exist!" + ), self.base_context.result.message.content + + @test + async def not_owner_no_perms(self) -> None: + other_id = self.base_author.id + 1 + tag_data = self._make_tag(name="secret", owner=other_id) + self._seed_guild(tags=[tag_data]) + original = self.base_channel.permissions_for + self.base_channel.permissions_for = lambda member: Permissions(manage_guild=False, send_messages=True) + await self.command(self.cog, self.base_context, name="secret") + self.base_channel.permissions_for = original + assert ( + self.base_context.result.message.content + == "You need to be tag owner or have the `manage server` permission to delete tags!" + ), self.base_context.result.message.content + + @test + async def delete_own_tag(self) -> None: + tag_data = self._make_tag(name="mine") + self._seed_guild(tags=[tag_data]) + await self.command(self.cog, self.base_context, name="mine") + assert ( + self.base_context.result.message.content + == "Successfully deleted tag `mine`" + ), self.base_context.result.message.content + + +class Info(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def tag_not_found(self) -> None: + self._seed_guild(tags=[]) + await self.command(self.cog, self.base_context, name="nonexistent") + assert ( + self.base_context.result.message.content + == "There is no tag with that name!" + ), self.base_context.result.message.content + + @test + async def tag_info_displayed(self) -> None: + tag_data = self._make_tag(name="hello", content="world", uses=5) + self._seed_guild(tags=[tag_data]) + await self.command(self.cog, self.base_context, name="hello") + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "hello" in self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + +class List(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def no_tags(self) -> None: + self._seed_guild() + await self.command(self.cog, self.base_context) + assert ( + self.base_context.result.message.content + == "Seems like this server doesn't have any tags!" + ), self.base_context.result.message.content + + @test + async def has_tags(self) -> None: + tag_data = self._make_tag(name="hello", uses=3) + self._seed_guild(tags=[tag_data]) + await self.command(self.cog, self.base_context) + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "Top tags of guild" in self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + @test + async def list_paginator_next_page(self) -> None: + """Paginator: tag list with >10 tags advances with next button.""" + tags = [self._make_tag(name=f"t{i}", uses=i, content="c") for i in range(25)] + self._seed_guild(tags=tags) + self.base_context.timeout_view = False + + async def _press_next(ctx): + await press_paginator_button( + ctx.current_view, + "next", + context=ctx, + message=ctx.result.message, + ) + ctx.current_view.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _press_next + try: + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context) + finally: + self.base_context.respond_to_view = _prev_rtv + emb = self.base_context.result.message.embeds[0] + fp = embed_footer_page(emb) + if fp is not None: + assert fp == (2, 3), fp + assert "`t" in emb.description, emb.description + + +class Transfer(TestingTags): + + def __init__(self): + super().__init__() + self.base_context.command = self.command + + @test + async def transfer_success(self) -> None: + recipient = DiscordMember( + id=self.base_author.id + 1000, + username="Recipient", + ) + self.base_guild.members = [self.base_author, recipient] + tag_data = self._make_tag(name="handoff", content="payload") + self._seed_guild(tags=[tag_data]) + + await self.command( + self.cog, + self.base_context, + user=recipient, + name="handoff", + ) + + content = self.base_context.result.message.content + assert "Successfully transferred tag `handoff`" in content, content + assert "Recipient" in content, content + + @test + async def tag_not_found(self) -> None: + self._seed_guild(tags=[]) + recipient = DiscordMember(id=self.base_author.id + 2000, username="R2") + self.base_guild.members = [self.base_author, recipient] + + await self.command( + self.cog, + self.base_context, + user=recipient, + name="missingtag", + ) + + assert ( + "does not exist" in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + @test + async def not_owner(self) -> None: + recipient = DiscordMember(id=self.base_author.id + 3000, username="R3") + self.base_guild.members = [self.base_author, recipient] + tag_data = self._make_tag(name="locked", owner=self.base_author.id + 9999) + self._seed_guild(tags=[tag_data]) + + await self.command( + self.cog, + self.base_context, + user=recipient, + name="locked", + ) + + assert ( + "tag owner" in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content + + +class Edit(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def tag_not_found(self) -> None: + self._seed_guild(tags=[]) + await self.command(self.cog, self.base_context, name="missing") + assert ( + self.base_context.result.message.content + == "A tag with that name does not exist!" + ) + + @test + async def not_owner(self) -> None: + tag_data = self._make_tag(name="owned", owner=self.base_author.id + 999) + self._seed_guild(tags=[tag_data]) + await self.command(self.cog, self.base_context, name="owned") + assert ( + self.base_context.result.message.content + == "You need to be tag owner to edit this tag!" + ) + + @test + async def updates_description(self) -> None: + tag_data = self._make_tag(name="editable", content="old text") + self._seed_guild(tags=[tag_data]) + + async def _text_response(ctx, prompt, **kwargs): + return "new description" + + self.cog.client.get_text_response = _text_response + await self.command(self.cog, self.base_context, name="editable") + assert ( + self.base_context.result.message.content + == "Successfully updated tag `editable`" + ) + updated = await Tag.new(self.base_guild.id, "editable") + assert updated.content == "new description" + + +class User(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def member_has_no_tags(self) -> None: + other = DiscordMember(id=self.base_author.id + 4000, username="NoTags") + self._seed_guild(tags=[self._make_tag(name="other", owner=self.base_author.id)]) + await self.command(self.cog, self.base_context, user=other) + assert ( + self.base_context.result.message.content + == "This user currently does not have any tags!" + ) + + @test + async def lists_owned_tags(self) -> None: + self._seed_guild( + tags=[ + self._make_tag(name="alpha", owner=self.base_author.id, uses=10), + self._make_tag(name="beta", owner=self.base_author.id, uses=3), + ] + ) + await self.command(self.cog, self.base_context, user=self.base_author) + emb = embed_at(self.base_context) + assert_embed_title(emb, "Top tags owned by") + assert "`alpha` with `10` uses" in (emb.description or "") + assert "`beta` with `3` uses" in (emb.description or "") + + +class Create(TestingTags): + + def __init__(self): + super().__init__() + + @test + async def tag_already_exists(self) -> None: + tag_data = self._make_tag(name="existing", content="old") + self._seed_guild(tags=[tag_data]) + + await self.command(self.cog, self.base_context, name="existing") + + assert ( + "already exists" in self.base_context.result.message.content + ), self.base_context.result.message.content diff --git a/killua/tests/groups/todo.py b/killua/tests/groups/todo.py new file mode 100644 index 000000000..a1a68fb54 --- /dev/null +++ b/killua/tests/groups/todo.py @@ -0,0 +1,722 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.todo import TodoSystem +from ...static.constants import DB, editing +from ...utils.classes.todo import TodoList, Todo + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from ..harnesses import embed_footer_page, patch_user_confirm_dm, press_paginator_button +from ..types import DiscordMember + + +def _clear_todo_state(): + """Reset TodoList caches, editing dict, and the todo DB collection.""" + TodoList.cache.clear() + TodoList.custom_id_cache.clear() + editing.clear() + DB.todo.db["todo"] = [] + + +async def _make_list(owner_id: int, enter_editing=True, **overrides) -> TodoList: + """Helper to create a todo list and optionally enter editor mode.""" + defaults = dict( + owner=owner_id, + title="Test list", + status="public", + done_delete=False, + ) + defaults.update(overrides) + todo_list = await TodoList.create(**defaults) + if enter_editing: + editing[owner_id] = todo_list.id + return todo_list + + +class TestingTodo(Testing): + requires_command = True + + _menus_registered = False + + def __init__(self): + if not TestingTodo._menus_registered: + TestingTodo._menus_registered = True + else: + TodoSystem._init_menus = lambda self: None + super().__init__(cog=TodoSystem) + + +class Create(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def create_basic(self) -> None: + _clear_todo_state() + await self.command( + self.cog, self.base_context, + name="My list", status="public", delete_when_done="no", + ) + + assert "Created the todo list with the name My list" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def name_too_long(self) -> None: + _clear_todo_state() + await self.command( + self.cog, self.base_context, + name="A" * 31, status="public", delete_when_done="no", + ) + + assert ( + self.base_context.result.message.content + == "Name can't be longer than 20 characters" + ), self.base_context.result.message.content + + @test + async def max_five_lists(self) -> None: + _clear_todo_state() + for i in range(5): + await TodoList.create( + owner=self.base_author.id, + title=f"list{i}", + status="public", + done_delete=False, + ) + + await self.command( + self.cog, self.base_context, + name="sixth", status="public", delete_when_done="no", + ) + + assert ( + self.base_context.result.message.content + == "You can currently not own more than 5 todo lists" + ), self.base_context.result.message.content + + @test + async def custom_id_not_premium(self) -> None: + _clear_todo_state() + await self.command( + self.cog, self.base_context, + name="Test", status="public", delete_when_done="no", + custom_id="mylist", + ) + + assert ( + self.base_context.result.message.content + == "You need to be a premium user to use custom ids" + ), self.base_context.result.message.content + + @test + async def custom_id_all_digits(self) -> None: + _clear_todo_state() + user = await User.new(self.base_author.id) + user._badges.append("tier_one") + + await self.command( + self.cog, self.base_context, + name="Test", status="public", delete_when_done="no", + custom_id="12345", + ) + + assert ( + self.base_context.result.message.content + == "Your custom id needs to contain at least one character that isn't an integer" + ), self.base_context.result.message.content + user._badges.remove("tier_one") + + @test + async def custom_id_too_long(self) -> None: + _clear_todo_state() + user = await User.new(self.base_author.id) + user._badges.append("tier_one") + + await self.command( + self.cog, self.base_context, + name="Test", status="public", delete_when_done="no", + custom_id="a" * 21, + ) + + assert ( + self.base_context.result.message.content + == "Your custom id can have max 20 characters" + ), self.base_context.result.message.content + user._badges.remove("tier_one") + + +class Edit(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def enter_edit_mode(self) -> None: + _clear_todo_state() + todo_list = await TodoList.create( + owner=self.base_author.id, + title="Editable", + status="public", + done_delete=False, + ) + + await self.command(self.cog, self.base_context, list_id=str(todo_list.id)) + + assert ( + self.base_context.result.message.content + == "You are now in editor mode for todo list 'Editable'" + ), self.base_context.result.message.content + assert editing[self.base_author.id] == todo_list.id + + @test + async def edit_nonexistent_list(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, list_id="999999") + + assert ( + self.base_context.result.message.content + == "No todo list with this id exists" + ), self.base_context.result.message.content + + @test + async def no_edit_permission(self) -> None: + _clear_todo_state() + todo_list = await TodoList.create( + owner=99999, + title="Private", + status="private", + done_delete=False, + ) + + await self.command(self.cog, self.base_context, list_id=str(todo_list.id)) + + assert ( + self.base_context.result.message.content + == "You do not have the permission to edit this todo list" + ), self.base_context.result.message.content + + +class Exit(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def exit_edit_mode(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.content == "Exiting editing mode!" + ), self.base_context.result.message.content + assert self.base_author.id not in editing + + @test + async def exit_without_editing(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context) + + assert "editor mode" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Add(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def add_todo(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, text="Buy milk") + + assert ( + self.base_context.result.message.content + == 'Great! Added "Buy milk" to your todo list!' + ), self.base_context.result.message.content + assert any( + t["todo"] == "Buy milk" for t in todo_list.todos + ), todo_list.todos + + @test + async def add_without_editing(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, text="Test") + + assert "editor mode" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + @test + async def add_text_too_long(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, text="A" * 101) + + assert ( + self.base_context.result.message.content + == "Your todo can't have more than 100 characters" + ), self.base_context.result.message.content + + @test + async def add_when_spots_full(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id) + for i in range(9): + todo_list.todos.append({ + "todo": f"todo {i+2}", "marked": None, "added_by": self.base_author.id, + "added_on": datetime.now(), "views": 0, "assigned_to": [], + "mark_log": [], "due_at": None, "notified": None, + }) + + await self.command(self.cog, self.base_context, text="Overflow") + + assert "don't have enough spots" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Remove(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def remove_todo(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, todo_numbers=[1]) + + assert "removed todo number 1 successfully" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def remove_invalid_number(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, todo_numbers=[99]) + + assert ( + self.base_context.result.message.content + == "All inputs are invalid task ids. Please try again." + ), self.base_context.result.message.content + + @test + async def remove_no_numbers(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, todo_numbers=[]) + + assert ( + self.base_context.result.message.content == "No valid numbers provided" + ), self.base_context.result.message.content + + @test + async def remove_without_editing(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, todo_numbers=[1]) + + assert "editor mode" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Mark(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def mark_as_text(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, todo_number=1, marked_as="in progress") + + assert ( + self.base_context.result.message.content + == "Marked to-do number 1 as `in progress`!" + ), self.base_context.result.message.content + + @test + async def mark_as_done_with_delete(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id, done_delete=True) + + await self.command(self.cog, self.base_context, todo_number=1, marked_as="done") + + assert ( + self.base_context.result.message.content + == "Marked to-do number 1 as done and deleted it per default" + ), self.base_context.result.message.content + + @test + async def remove_mark(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, todo_number=1, marked_as="-r") + + assert ( + self.base_context.result.message.content + == "Removed to-do number 1 successfully!" + ), self.base_context.result.message.content + + @test + async def mark_invalid_number(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, todo_number=99, marked_as="done") + + assert "don't have a number 99" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + @test + async def mark_without_editing(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, todo_number=1, marked_as="done") + + assert "editor mode" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Clear(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def clear_todos(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context) + + assert ( + self.base_context.result.message.content == "Done! Cleared all your todos" + ), self.base_context.result.message.content + assert len(todo_list.todos) == 0, todo_list.todos + + @test + async def clear_without_editing(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context) + + assert "editor mode" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Delete(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def delete_own_list(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id, title="Goodbye") + + await self.command(self.cog, self.base_context, todo_id=str(todo_list.id)) + + assert ( + self.base_context.result.message.content + == "Done! Deleted todo list Goodbye" + ), self.base_context.result.message.content + assert todo_list.id not in TodoList.cache + + @test + async def delete_not_owner(self) -> None: + _clear_todo_state() + todo_list = await TodoList.create( + owner=99999, title="NotMine", status="public", done_delete=False, + ) + + await self.command(self.cog, self.base_context, todo_id=str(todo_list.id)) + + assert ( + self.base_context.result.message.content + == "Only the owner of a todo list can delete it" + ), self.base_context.result.message.content + + @test + async def delete_nonexistent(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, todo_id="999999") + + assert ( + self.base_context.result.message.content + == "No todo list with this id exists" + ), self.base_context.result.message.content + + +class View(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def view_nonexistent(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, todo_id="999999") + + assert ( + self.base_context.result.message.content + == "No todo list with specified ID found" + ), self.base_context.result.message.content + + @test + async def view_private_no_permission(self) -> None: + _clear_todo_state() + todo_list = await TodoList.create( + owner=99999, title="Secret", status="private", done_delete=False, + ) + + await self.command(self.cog, self.base_context, todo_id=str(todo_list.id)) + + assert ( + self.base_context.result.message.content + == "This is a private list you don't have the permission to view" + ), self.base_context.result.message.content + + @test + async def view_public_list(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id, title="Visible") + + await self.command(self.cog, self.base_context, todo_id=str(todo_list.id)) + + assert self.base_context.result.message.embeds, ( + self.base_context.result.message.embeds + ) + assert f'To-do list "Visible"' in ( + self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + @test + async def view_paginator_next_page(self) -> None: + """Paginator: long todo list view advances with next.""" + _clear_todo_state() + todo_list = await _make_list(self.base_author.id, enter_editing=False) + row = { + "todo": "x", + "marked": None, + "added_by": self.base_author.id, + "added_on": datetime.now(), + "views": 0, + "assigned_to": [], + "mark_log": [], + "due_at": None, + "notified": None, + } + for i in range(11): + d = dict(row) + d["todo"] = f"task-{i}" + todo_list.todos.append(d) + await todo_list.set_property("todos", todo_list.todos) + + self.base_context.timeout_view = False + + async def _pn(ctx): + await press_paginator_button( + ctx.current_view, + "next", + context=ctx, + message=ctx.result.message, + ) + ctx.current_view.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _pn + try: + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context, todo_id=str(todo_list.id)) + finally: + self.base_context.respond_to_view = _prev_rtv + emb = self.base_context.result.message.embeds[0] + assert "Page 2/2" in emb.description, emb.description + fp = embed_footer_page(emb) + if fp is not None: + assert fp == (2, 2), fp + + +class Invite(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def invite_editor_accept_via_dm_confirm(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id, status="private") + invitee = DiscordMember( + id=self.base_author.id + 9001, + username="EditorFriend", + mutual_guilds=[object()], + ) + await patch_user_confirm_dm(invitee, self.base_context, invitee=invitee) + with patch("killua.cogs.todo.blcheck", AsyncMock(return_value=False)): + await self.command( + self.cog, self.base_context, user=invitee, role="editor" + ) + + todo_list = await TodoList.new(editing[self.base_author.id]) + assert invitee.id in todo_list.editor, todo_list.editor + + @test + async def invite_denied_via_dm_cancel(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id, status="private") + invitee = DiscordMember( + id=self.base_author.id + 9002, + username="DenyFriend", + mutual_guilds=[object()], + ) + await patch_user_confirm_dm( + invitee, self.base_context, invitee=invitee, confirm=False + ) + with patch("killua.cogs.todo.blcheck", AsyncMock(return_value=False)): + await self.command( + self.cog, self.base_context, user=invitee, role="editor" + ) + + todo_list = await TodoList.new(editing[self.base_author.id]) + assert invitee.id not in todo_list.editor, todo_list.editor + assert invitee.id not in todo_list.viewer, todo_list.viewer + + @test + async def cannot_invite_self(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + await self.command( + self.cog, self.base_context, user=self.base_author, role="editor" + ) + assert "don't need to invite yourself" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Assign(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def assign_task_to_editor(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id) + editor = DiscordMember(id=self.base_author.id + 9010, username="Ed") + todo_list.editor.append(editor.id) + await todo_list.set_property("editor", todo_list.editor) + todo_list.todos.append({ + "todo": "task one", + "marked": None, + "added_by": self.base_author.id, + "added_on": datetime.now(), + "views": 0, + "assigned_to": [], + "mark_log": [], + "due_at": None, + "notified": None, + }) + await todo_list.set_property("todos", todo_list.todos) + + await self.command( + self.cog, self.base_context, todo_number=1, user=editor + ) + assert "Successfully assigned" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + +class Kick(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def kick_editor(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id) + editor_id = self.base_author.id + 9020 + await todo_list.add_editor(editor_id) + + editor = DiscordMember(id=editor_id, username="KickMe") + await self.command(self.cog, self.base_context, user=editor) + assert "taken the editor permission" in ( + self.base_context.result.message.content + ), self.base_context.result.message.content + todo_list = await TodoList.new(todo_list.id) + assert editor_id not in todo_list.editor + + +class Reorder(TestingTodo): + + def __init__(self): + super().__init__() + + @test + async def reorder_success(self) -> None: + _clear_todo_state() + todo_list = await _make_list(self.base_author.id) + todo_list.todos.append({ + "todo": "second task", "marked": None, "added_by": self.base_author.id, + "added_on": datetime.now(), "views": 0, "assigned_to": [], + "mark_log": [], "due_at": None, "notified": None, + }) + + await self.command(self.cog, self.base_context, position=1, new_position=2) + + assert ( + self.base_context.result.message.content + == "Successfully reordered todo task 1 to position 2" + ), self.base_context.result.message.content + + @test + async def reorder_invalid_position(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, position=99, new_position=1) + + assert "don't have a number 99" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + @test + async def reorder_out_of_range(self) -> None: + _clear_todo_state() + await _make_list(self.base_author.id) + + await self.command(self.cog, self.base_context, position=1, new_position=99) + + assert "out of range" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) + + @test + async def reorder_without_editing(self) -> None: + _clear_todo_state() + await self.command(self.cog, self.base_context, position=1, new_position=2) + + assert "editor mode" in self.base_context.result.message.content, ( + self.base_context.result.message.content + ) diff --git a/killua/tests/groups/unit_boost.py b/killua/tests/groups/unit_boost.py new file mode 100644 index 000000000..552c3fc27 --- /dev/null +++ b/killua/tests/groups/unit_boost.py @@ -0,0 +1,1008 @@ +"""Direct unit tests for utils and API helpers (high statement density).""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock, patch + +import discord +from PIL import Image +from discord.ext import commands +from discord.ext.commands import BadArgument + +from ..testing import Testing, test, collect_test_classes +from ..types import Bot, DiscordMember, Role +from ...cogs.economy import Economy +from ...cogs.api import IPCRoutes +from ...cogs.image_manipulation import ImageManipulation +from ...static.constants import DB, LOOTBOXES, daily_users +from ...static.enums import Booster +from ...utils.checks import ( + blcheck, + premium_guild_only, + premium_user_only, + CommandUsageCache, + check, +) +from ...utils.classes import User, Guild +from ...utils.classes.lootbox import LootBox +from ...utils.classes.book import Book +from ...utils.converters import TimeConverter +from ...utils.gif import TransparentAnimatedGifConverter, save_transparent_gif +from ...utils.interactions import View, Modal, Button as KButton +from ...utils.paginator import Paginator, DefaultEmbed + +import killua.utils.checks as checks_module + + +class TestingUnitBoost(Testing): + _menus_registered = False + + def __init__(self) -> None: + if not TestingUnitBoost._menus_registered: + TestingUnitBoost._menus_registered = True + else: + Economy._init_menus = lambda self: None + super().__init__(cog=Economy) + + @property + def all_tests(self): + return collect_test_classes(self.__class__) + + +class _UnitBoostTests(TestingUnitBoost): + pass + + +def _settings_doc(*, enabled=True, channel_id=None, blacklisted=None): + cid = str(channel_id) + return { + "enabled": enabled, + "blacklisted_channels": list(blacklisted or []), + "restricted_to_channels": [cid], + "restricted_to_roles": [], + "blacklisted_roles": [], + } + + +class _FakeCommand: + def __init__(self, name: str, cmd_id: str): + self.name = name + self.extras = {"id": cmd_id} + + +def _check_context(testing, *, command_name="daily", command_id="99"): + cmd = _FakeCommand(command_name, command_id) + testing.base_context.command = cmd + return testing.base_context + + +class TimeConverterUnit(_UnitBoostTests): + @test + async def parses_compound_duration(self) -> None: + conv = TimeConverter() + assert await conv.convert(self.base_context, "1h") == timedelta(hours=1) + assert await conv.convert(self.base_context, "30m") == timedelta(minutes=30) + assert await conv.convert(self.base_context, "1h30m") == timedelta( + hours=1, minutes=30 + ) + + @test + async def rejects_over_28_days(self) -> None: + conv = TimeConverter() + try: + await conv.convert(self.base_context, "29d") + assert False, "expected BadArgument" + except BadArgument as exc: + assert "28 days" in str(exc).lower() + + @test + async def rejects_unknown_unit_via_dict(self) -> None: + conv = TimeConverter() + with patch.dict( + "killua.utils.converters.time_dict", {"h": 3600}, clear=True + ): + try: + await conv.convert(self.base_context, "1m") + assert False, "expected BadArgument" + except BadArgument as exc: + assert "invalid time-key" in str(exc).lower() + + @test + async def rejects_non_numeric_value(self) -> None: + conv = TimeConverter() + with patch("killua.utils.converters.float", side_effect=ValueError): + try: + await conv.convert(self.base_context, "1h") + assert False, "expected BadArgument" + except BadArgument as exc: + assert "not a number" in str(exc).lower() + + +class ChecksUnit(_UnitBoostTests): + @test + async def blcheck_not_listed(self) -> None: + DB.const.db["const"] = [{"_id": "blacklist", "blacklist": []}] + assert await blcheck(self.base_author.id) is False + + @test + async def blcheck_listed(self) -> None: + DB.const.db["const"] = [ + { + "_id": "blacklist", + "blacklist": [{"id": self.base_author.id, "reason": "x"}], + } + ] + assert await blcheck(self.base_author.id) is True + + @test + async def premium_guild_denied(self) -> None: + Guild.cache.pop(self.base_guild.id, None) + guild = await Guild.new(self.base_guild.id) + guild.badges = [] + pred = premium_guild_only().predicate + assert await pred(self.base_context) is False + + @test + async def premium_user_denied(self) -> None: + uid = self.base_author.id + User.cache.pop(uid, None) + await User.new(uid) + await DB.teams.update_one({"id": uid}, {"$set": {"badges": []}}) + User.cache.pop(uid, None) + pred = premium_user_only().predicate + assert await pred(self.base_context) is False + + @test + async def premium_guild_allowed(self) -> None: + Guild.cache.pop(self.base_guild.id, None) + guild = await Guild.new(self.base_guild.id) + guild.badges = ["premium"] + pred = premium_guild_only().predicate + assert await pred(self.base_context) is True + + @test + async def premium_user_allowed(self) -> None: + uid = self.base_author.id + User.cache.pop(uid, None) + await User.new(uid) + await DB.teams.update_one({"id": uid}, {"$set": {"badges": ["6002630"]}}) + User.cache.pop(uid, None) + pred = premium_user_only().predicate + assert await pred(self.base_context) is True + + @test + async def command_usage_cache(self) -> None: + cache = CommandUsageCache() + cache.data = {"1": 2} + assert cache.get("1", 0) == 2 + assert "1" in cache + await cache.set("2", 5) + assert cache.data["2"] == 5 + + @test + async def check_predicate_tracks_usage(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {"99": 1}}, + ] + daily_users.users.clear() + checks_module.cooldowndict = {} + await Guild.new(self.base_guild.id) + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is True + row = next(d for d in DB.const.db["const"] if d["_id"] == "usage") + assert row["command_usage"]["99"] == 2 + + @test + async def check_predicate_cooldown_blocks_second_call(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + await Guild.new(self.base_guild.id) + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=120).predicate + assert await pred(ctx) is True + assert await pred(ctx) is False + emb = ctx.result.message.embeds + if isinstance(emb, list) and emb: + title = emb[0].title or "" + elif isinstance(emb, tuple) and emb and isinstance(emb[0], list) and emb[0]: + title = emb[0][0].title or "" + else: + title = "" + assert title == "Cooldown" + + @test + async def check_settings_disabled_command(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + guild = await Guild.new(self.base_guild.id) + guild.commands = {"daily": _settings_doc(enabled=False, channel_id=self.base_channel.id)} + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is False + + @test + async def check_settings_blacklisted_channel(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + guild = await Guild.new(self.base_guild.id) + cid = str(self.base_channel.id) + guild.commands = { + "daily": _settings_doc( + channel_id=self.base_channel.id, + blacklisted=[cid], + ) + } + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is False + + @test + async def check_settings_restricted_channel(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + guild = await Guild.new(self.base_guild.id) + guild.commands = { + "daily": _settings_doc(channel_id=self.base_channel.id + 999) + } + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is False + + @test + async def check_settings_blacklisted_role(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + Guild.cache.pop(self.base_guild.id, None) + guild = await Guild.new(self.base_guild.id) + rid = 88001 + self.base_author.roles = [Role(id=rid, position=1)] + ctx = _check_context(self) + guild.commands = { + "daily": { + **_settings_doc(channel_id=self.base_channel.id), + "blacklisted_roles": [str(rid)], + } + } + await User.new(self.base_author.id) + pred = check(time=0).predicate + assert await pred(ctx) is False + + @test + async def check_settings_requires_role(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + Guild.cache.pop(self.base_guild.id, None) + guild = await Guild.new(self.base_guild.id) + self.base_author.roles = [Role(id=88003, position=1)] + ctx = _check_context(self) + guild.commands = { + "daily": { + **_settings_doc(channel_id=self.base_channel.id), + "restricted_to_roles": ["88002"], + } + } + await User.new(self.base_author.id) + pred = check(time=0).predicate + assert await pred(ctx) is False + + @test + async def check_cooldown_premium_guild_halves_wait(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + guild = await Guild.new(self.base_guild.id) + guild.badges = ["premium"] + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=60).predicate + assert await pred(ctx) is True + assert await pred(ctx) is False + + @test + async def check_predicate_blacklisted_user(self) -> None: + DB.const.db["const"] = [ + { + "_id": "blacklist", + "blacklist": [{"id": self.base_author.id, "reason": "x"}], + }, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + await Guild.new(self.base_guild.id) + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is False + + @test + async def check_settings_allows_matching_role(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + Guild.cache.pop(self.base_guild.id, None) + guild = await Guild.new(self.base_guild.id) + rid = 88002 + self.base_author.roles = [Role(id=rid, position=1)] + guild.commands = { + "daily": { + **_settings_doc(channel_id=self.base_channel.id), + "restricted_to_channels": [self.base_channel.id], + "restricted_to_roles": [str(rid)], + } + } + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is True + + @test + async def check_settings_delete_invocation(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + Guild.cache.pop(self.base_guild.id, None) + guild = await Guild.new(self.base_guild.id) + ctx = _check_context(self) + guild.commands = { + "daily": { + **_settings_doc(channel_id=self.base_channel.id), + "restricted_to_channels": [self.base_channel.id], + "delete_invokation": True, + } + } + await User.new(self.base_author.id) + pred = check(time=0).predicate + assert await pred(ctx) is True + + @test + async def check_predicate_without_guild(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + daily_users.users.clear() + await User.new(self.base_author.id) + ctx = _check_context(self) + prev_guild = ctx.guild + ctx.guild = None + try: + pred = check(time=0).predicate + assert await pred(ctx) is True + finally: + ctx.guild = prev_guild + + @test + async def check_cooldown_new_command_same_user(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + await Guild.new(self.base_guild.id) + await User.new(self.base_author.id) + ctx = _check_context(self, command_name="daily", command_id="99") + cmd2 = MagicMock(spec=commands.Command) + cmd2.name = "othercmd" + cmd2.extras = {"id": "100"} + pred = check(time=120).predicate + assert await pred(ctx) is True + ctx.command = cmd2 + assert await pred(ctx) is True + + @test + async def check_cooldown_expires(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + await Guild.new(self.base_guild.id) + await User.new(self.base_author.id) + ctx = _check_context(self) + checks_module.cooldowndict = { + self.base_author.id: { + "daily": datetime.now() - timedelta(seconds=200), + } + } + pred = check(time=60).predicate + assert await pred(ctx) is True + + @test + async def check_cooldown_premium_user_halves_wait(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + await Guild.new(self.base_guild.id) + uid = self.base_author.id + await User.new(uid) + await DB.teams.update_one({"id": uid}, {"$set": {"badges": ["6002630"]}}) + User.cache.pop(uid, None) + ctx = _check_context(self) + pred = check(time=120).predicate + assert await pred(ctx) is True + assert await pred(ctx) is False + + @test + async def check_skips_hybrid_group_usage(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {"99": 0}}, + ] + checks_module.cooldowndict = {} + Guild.cache.pop(self.base_guild.id, None) + await Guild.new(self.base_guild.id) + await User.new(self.base_author.id) + ctx = _check_context(self) + ctx.command = MagicMock(spec=commands.HybridGroup) + ctx.command.name = "daily" + ctx.command.extras = {"id": "99"} + pred = check(time=0).predicate + assert await pred(ctx) is True + row = next(d for d in DB.const.db["const"] if d["_id"] == "usage") + assert row["command_usage"]["99"] == 0 + + @test + async def check_user_installed_usage(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + Guild.cache.pop(self.base_guild.id, None) + await Guild.new(self.base_guild.id) + user = await User.new(self.base_author.id) + ctx = _check_context(self) + with patch.object( + user, "register_user_installed_usage", AsyncMock() + ) as reg: + with patch.object(User, "new", AsyncMock(return_value=user)): + with patch.object( + type(ctx.bot), "is_user_installed", return_value=True + ): + pred = check(time=0).predicate + assert await pred(ctx) is True + reg.assert_awaited_once() + + @test + async def check_settings_bad_data_is_ignored(self) -> None: + DB.const.db["const"] = [ + {"_id": "blacklist", "blacklist": []}, + {"_id": "usage", "command_usage": {}}, + ] + checks_module.cooldowndict = {} + guild = await Guild.new(self.base_guild.id) + guild.commands = {"daily": "broken"} + await User.new(self.base_author.id) + ctx = _check_context(self) + pred = check(time=0).predicate + assert await pred(ctx) is True + + +class BookUnit(_UnitBoostTests): + @test + async def background_cache_and_fetch(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGB", (32, 32), "white").save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + book = Book(Bot) + cached = Image.new("RGBA", (20, 20), (1, 2, 3, 255)) + book._set_cache(cached, first_page=True) + assert book._get_from_cache(0) is not None + bg = await book._get_background(0) + assert bg.size[0] > 0 + book.card_cache["1"] = cached + data = [(1, "http://cards.example/1.png")] + merged = await book._cards(bg.copy(), data, option=0) + assert merged.size == bg.size + + @test + async def create_image_restricted_first_page(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGB", (640, 420), "white").save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + book = Book(Bot) + data = [ + [i, None if i % 2 else "http://cards.example/card.png"] + for i in range(1, 11) + ] + img = await book.create_image(data, restricted_slots=True, page=1) + assert img.size[0] > 0 + + @test + async def default_background_fetch(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGB", (32, 32), "white").save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + book = Book(Bot) + book.background_cache.clear() + bg = await book._get_background(1) + assert bg.size[0] > 0 + assert book._get_from_cache(1) is not None + + @test + async def set_page_and_numbers(self) -> None: + book = Book(Bot) + bg = Image.new("RGBA", (640 * book.scalar, 420 * book.scalar), (255, 255, 255, 255)) + data = [[i, None] for i in range(1, 11)] + numbered = book._numbers(bg.copy(), data, page=1) + paged = book._set_page(numbered, page=2) + assert paged.size == bg.size + + @test + async def create_image_and_fetch_card(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGB", (640, 420), "white").save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + book = Book(Bot) + book.background_cache.clear() + book.card_cache.clear() + data = [ + [i, None if i % 2 else "http://cards.example/card.png"] + for i in range(1, 11) + ] + img = await book.create_image(data, restricted_slots=True, page=1) + assert img.size[0] > 0 + + @test + async def fetch_card_image(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGBA", (84, 115), (255, 0, 0, 255)).save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + book = Book(Bot) + card = await book._get_card("http://cards.example/7.png") + assert card.size[0] > 0 + + +class PaginatorUnit(_UnitBoostTests): + @test + async def first_page_from_strings(self) -> None: + pag = Paginator(self.base_context, pages=["alpha", "beta"]) + await pag._get_first_embed() + assert pag.embed.description == "alpha" + + @test + async def custom_embed_formatter(self) -> None: + def fmt(page, emb, pages): + emb.description = pages[page - 1].upper() + return emb + + pag = Paginator( + self.base_context, pages=["page"], func=fmt, embed=DefaultEmbed() + ) + await pag._get_first_embed() + assert pag.embed.description == "PAGE" + + @test + async def turn_page_with_buttons(self) -> None: + import asyncio + + from ..harnesses.paginator import embed_footer_page, press_paginator_button + + pag = Paginator(self.base_context, pages=["one", "two", "three"]) + task = asyncio.create_task(pag.start()) + await asyncio.sleep(0) + await press_paginator_button(pag.view, "next", context=self.base_context) + footer = embed_footer_page(pag.embed) + assert footer is not None and footer[0] == 2 + pag.view.stop() + await task + + @test + async def paginator_first_and_last(self) -> None: + import asyncio + + from ..harnesses.paginator import press_paginator_button + + pag = Paginator(self.base_context, pages=["a", "b", "c"]) + task = asyncio.create_task(pag.start()) + await asyncio.sleep(0) + await press_paginator_button(pag.view, "last", context=self.base_context) + assert pag.view.page == 3 + await press_paginator_button(pag.view, "first", context=self.base_context) + assert pag.view.page == 1 + pag.view.stop() + await task + + @test + async def paginator_stop_button(self) -> None: + import asyncio + + from ..harnesses.paginator import press_paginator_button + + pag = Paginator(self.base_context, pages=["only"]) + task = asyncio.create_task(pag.start()) + await asyncio.sleep(0) + await press_paginator_button(pag.view, "delete", context=self.base_context) + pag.view.stop() + await task + + +class ImageManipulationUnit(_UnitBoostTests): + def _cog(self) -> ImageManipulation: + if not hasattr(ImageManipulationUnit, "_cog_instance"): + ImageManipulationUnit._cog_instance = ImageManipulation(Bot) + return ImageManipulationUnit._cog_instance + + @test + async def crop_to_circle(self) -> None: + cog = self._cog() + im = Image.new("RGBA", (40, 40), (255, 0, 0, 255)) + circ = cog._crop_to_circle(im) + assert circ.size == im.size + + @test + async def create_spin_frames(self) -> None: + cog = self._cog() + im = Image.new("RGBA", (20, 20), (255, 0, 0, 255)) + frames = cog._create_frames(im) + assert len(frames) == 17 + + @test + async def put_horizontally(self) -> None: + cog = self._cog() + im1 = Image.new("RGBA", (20, 10), (255, 0, 0, 255)) + im2 = Image.new("RGBA", (30, 30), (0, 255, 0, 255)) + out = cog._put_horizontally(im1, im2) + assert out.width == im2.width + + @test + async def get_image_bytes(self) -> None: + class Resp: + async def read(self): + return b"\x89PNG\r\n\x1a\n" + b"x" * 20 + + Bot.session.get = AsyncMock(return_value=Resp()) + buf = await self._cog()._get_image_bytes("http://example.com/x.png") + assert buf.getvalue()[:8] == b"\x89PNG\r\n\x1a\n" + + @test + async def create_spin_gif(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGB", (60, 40), "red").save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + out = await self._cog()._create_spin_gif("http://example.com/face.png") + assert out.getvalue()[:6] == b"GIF89a" + + @test + async def create_wtf_meme(self) -> None: + class Resp: + async def read(self): + buf = BytesIO() + Image.new("RGBA", (40, 20), (0, 255, 0, 255)).save(buf, format="PNG") + return buf.getvalue() + + Bot.session.get = AsyncMock(return_value=Resp()) + cog = self._cog() + cog.wtf_meme = None + out = await cog.create_wtf_meme("http://example.com/src.png") + assert out.getvalue()[:8] == b"\x89PNG\r\n\x1a\n" + + @test + async def get_target_url_from_attachment(self) -> None: + cog = self._cog() + att = MagicMock() + att.url = "https://example.com/attached.png" + self.base_message.attachments = [att] + url = await cog._validate_input(self.base_context, None) + assert url == "https://example.com/attached.png" + + @test + async def validate_input_accepts_plain_url(self) -> None: + cog = self._cog() + with patch.object( + commands.MemberConverter, + "convert", + side_effect=commands.MemberNotFound("x"), + ): + with patch.object( + commands.EmojiConverter, + "convert", + side_effect=commands.EmojiNotFound("x"), + ): + url = await cog._validate_input( + self.base_context, "https://example.com/pic.png" + ) + assert url == "https://example.com/pic.png" + + +class ApiHelpersUnit(_UnitBoostTests): + def _ipc(self) -> IPCRoutes: + from .api import TestingApi + + if TestingApi._ipc is None: + TestingApi._ipc = IPCRoutes(Bot) + return TestingApi._ipc + + @test + async def get_reward_streaks(self) -> None: + ipc = self._ipc() + assert ipc._get_reward(0) == 100 + r5 = ipc._get_reward(5) + assert isinstance(r5, (int, Booster)) + r7 = ipc._get_reward(7, weekend=True) + assert isinstance(r7, (int, Booster)) + + @test + async def create_path_short_streak(self) -> None: + ipc = self._ipc() + user = MagicMock() + path = ipc._create_path(3, user, "http://x") + assert len(path) == 11 + assert path[2] is user + + @test + async def create_path_long_streak(self) -> None: + ipc = self._ipc() + user = MagicMock() + path = ipc._create_path(10, user, "http://x") + assert len(path) == 11 + assert path[5] is user + + @test + async def format_command_metadata(self) -> None: + ipc = self._ipc() + cmd = MagicMock() + cmd.checks = [] + cmd.qualified_name = "economy daily" + cmd.name = "daily" + cmd.usage = "" + cmd.help = "help" + cmd.aliases = [] + cmd.cog = MagicMock(spec=commands.Cog) + out = ipc.format_command(cmd) + assert out["name"] == "daily" + + @test + async def user_edit_fields(self) -> None: + ipc = self._ipc() + uid = self.base_author.id + await User.new(uid) + res = await ipc.user_edit( + {"user_id": uid, "voting_reminder": True, "email_notifications": False} + ) + assert res["success"] is True + + @test + async def streak_image_builds(self) -> None: + from ..types import DiscordUser + + ipc = self._ipc() + user = DiscordUser(id=self.base_author.id) + path = ipc._create_path(5, user, "http://api") + buf = BytesIO() + Image.new("RGBA", (100, 100), (0, 0, 0, 0)).save(buf, format="PNG") + buf.seek(0) + + async def fake_dl(_url): + im = Image.open(buf).convert("RGBA") + return im + + ipc.download = fake_dl + out = await ipc.streak_image(path) + assert out.getvalue()[:8] == b"\x89PNG\r\n\x1a\n" + + @test + async def news_delete_existing(self) -> None: + ipc = self._ipc() + DB.news.db["news"] = [ + {"_id": "del1", "type": "news", "messageId": None, "published": False} + ] + with patch.object(ipc, "_delete_discord_message", AsyncMock()): + res = await ipc.news_delete({"news_id": "del1"}) + assert res["status"] == "deleted" + + +class LootboxUnit(_UnitBoostTests): + @test + async def generate_rewards(self) -> None: + rewards = await LootBox.generate_rewards(1) + assert isinstance(rewards, list) + assert len(rewards) > 0 + + @test + async def booster_select_options(self) -> None: + from killua.utils.classes.lootbox import _BoosterSelect + + sel = _BoosterSelect(used=[], inventory={"1": 2}) + assert len(sel.options) >= 0 + + +class GifUnit(_UnitBoostTests): + @test + async def transparent_gif_converter(self) -> None: + img = Image.new("RGBA", (4, 4), (255, 0, 0, 128)) + conv = TransparentAnimatedGifConverter(img, alpha_threshold=128) + out = conv.process() + assert out is not None + + @test + async def save_transparent_gif(self) -> None: + frames = [ + Image.new("RGBA", (4, 4), (255, 0, 0, 255)), + Image.new("RGBA", (4, 4), (0, 255, 0, 255)), + ] + buf = BytesIO() + save_transparent_gif(frames, 100, buf) + assert buf.tell() > 0 + + +class InteractionsUnit(_UnitBoostTests): + @test + async def view_allows_owner(self) -> None: + from ..harnesses.interaction import MockComponentInteraction + + view = View(self.base_author.id) + ix = MockComponentInteraction( + context=self.base_context, + user=self.base_author, + custom_id="x", + message=self.base_message, + client=Bot, + ) + assert await view.interaction_check(ix) is True + + @test + async def view_denies_other_user(self) -> None: + from ..harnesses.interaction import MockComponentInteraction + from ..types import DiscordMember + + view = View(self.base_author.id) + other = DiscordMember(id=self.base_author.id + 1, username="other") + ix = MockComponentInteraction( + context=self.base_context, + user=other, + custom_id="x", + message=self.base_message, + client=Bot, + ) + ix.response.defer = AsyncMock() + assert await view.interaction_check(ix) is False + + @test + async def modal_timeout_flag(self) -> None: + modal = Modal(title="t") + assert modal.timed_out is False + await modal.on_timeout() + assert modal.timed_out is True + + @test + async def killua_button_sets_value(self) -> None: + from ..types import ArgumentInteraction + + view = View(self.base_author.id) + btn = KButton(label="go", custom_id="go-btn") + view.add_item(btn) + await btn.callback(ArgumentInteraction(self.base_context)) + assert view.value == "go-btn" + + @test + async def view_disable_skips_when_already_disabled(self) -> None: + view = View(self.base_author.id) + btn = discord.ui.Button(label="x", custom_id="x", disabled=True) + view.add_item(btn) + msg = MagicMock() + msg.edit = AsyncMock() + assert await view.disable(msg) is None + + +class CardsStaticUnit(_UnitBoostTests): + @test + async def import_spell_classes(self) -> None: + from killua.static import cards as cards_mod + import inspect + + count = 0 + for name, cls in inspect.getmembers(cards_mod, inspect.isclass): + if name.startswith("Card10") and hasattr(cls, "exec"): + count += 1 + assert count >= 10 + + +class LootboxDeepUnit(_UnitBoostTests): + @test + async def lootbox_view_and_sku(self) -> None: + import discord as d + + ctx = self.base_context + rewards = [10, None, None] + box = LootBox(ctx, rewards) + view = box._create_view() + assert len(view.children) == 25 + sku = MagicMock() + sku.name = "titans crate" + assert LootBox.get_lootbox_from_sku(sku)[0] == 7 + + +class ApiMoreUnit(_UnitBoostTests): + def _ipc(self) -> IPCRoutes: + from .api import TestingApi + + if TestingApi._ipc is None: + TestingApi._ipc = IPCRoutes(Bot) + return TestingApi._ipc + + @test + async def guild_command_usage_outside_docker(self) -> None: + ipc = self._ipc() + prev = Bot.run_in_docker + Bot.run_in_docker = False + try: + res = await ipc.guild_command_usage({"guild_id": self.base_guild.id}) + assert res.get("error") + finally: + Bot.run_in_docker = prev + + @test + async def get_message_command(self) -> None: + ipc = self._ipc() + cmd = ipc.get_message_command("daily") + assert cmd is not None or cmd is None + + @test + async def convert_datetime_and_snowflakes(self) -> None: + ipc = self._ipc() + dt = datetime(2024, 1, 2, 3, 4, 5) + assert ipc._convert_datetime(dt) == dt.isoformat() + snow = ipc._convert_snowflakes(1234567890123456789) + assert isinstance(snow, str) + nested = ipc._convert_datetime({"ts": dt, "items": [dt]}) + assert nested["ts"] == dt.isoformat() diff --git a/killua/tests/groups/web_scraping.py b/killua/tests/groups/web_scraping.py new file mode 100644 index 000000000..cdfd018df --- /dev/null +++ b/killua/tests/groups/web_scraping.py @@ -0,0 +1,194 @@ +from ..types import * +from ...utils.classes import * +from ..testing import Testing, test +from ...cogs.web_scraping import WebScraping + +import inspect +from unittest.mock import AsyncMock, patch + +from ..harnesses import embed_footer_page, press_paginator_button + + +class MockResponse: + def __init__(self, status=200, data=None): + self.status = status + self._data = data or {} + + async def json(self): + return self._data + + async def text(self): + return "" + + +class MockPxlResult: + def __init__(self, success=True, data=None, error=""): + self.success = success + self.data = data or {} + self.error = error + + +class TestingWebScraping(Testing): + requires_command = True + _menus_registered = False + + def __init__(self): + if not TestingWebScraping._menus_registered: + TestingWebScraping._menus_registered = True + else: + WebScraping._init_menus = lambda self: None + super().__init__(cog=WebScraping) + + +class Novel(TestingWebScraping): + + def __init__(self): + super().__init__() + + @test + async def no_results(self) -> None: + original = self.cog.client.session.get + self.cog.client.session.get = AsyncMock( + return_value=MockResponse(200, {"numFound": 0, "docs": []}) + ) + await self.command(self.cog, self.base_context, book="xyznonexistent999") + self.cog.client.session.get = original + assert ( + self.base_context.result.message.content == "No results found" + ), self.base_context.result.message.content + + @test + async def api_error(self) -> None: + original = self.cog.client.session.get + self.cog.client.session.get = AsyncMock(return_value=MockResponse(500)) + await self.command(self.cog, self.base_context, book="test") + self.cog.client.session.get = original + assert ( + "Something went wrong" in self.base_context.result.message.content + ), self.base_context.result.message.content + + +class Img(TestingWebScraping): + + def __init__(self): + super().__init__() + + @test + async def no_results(self) -> None: + original = self.cog.get_bing_images + self.cog.get_bing_images = AsyncMock(return_value=[]) + await self.command(self.cog, self.base_context, query="xyznonexistent") + self.cog.get_bing_images = original + assert ( + self.base_context.result.message.content == "No results found" + ), self.base_context.result.message.content + + @test + async def api_error(self) -> None: + original = self.cog.get_bing_images + self.cog.get_bing_images = AsyncMock(return_value=500) + await self.command(self.cog, self.base_context, query="test") + self.cog.get_bing_images = original + assert ( + "Something went wrong" in self.base_context.result.message.content + and "500" in self.base_context.result.message.content + ), self.base_context.result.message.content + + @test + async def img_paginator_next_page(self) -> None: + """Paginator: press next on multi-image img results.""" + links = [f"https://example.com/img{i}.png" for i in range(5)] + orig = self.cog.get_bing_images + self.cog.get_bing_images = AsyncMock(return_value=links) + self.base_context.timeout_view = False + + async def _press_next(ctx): + await press_paginator_button( + ctx.current_view, + "next", + context=ctx, + message=ctx.result.message, + ) + ctx.current_view.stop() + + _prev_rtv = self.base_context.respond_to_view + self.base_context.respond_to_view = _press_next + try: + with patch("killua.bot.randint", return_value=100): + await self.command(self.cog, self.base_context, query="cats") + finally: + self.cog.get_bing_images = orig + self.base_context.respond_to_view = _prev_rtv + raw = self.base_context.result.message.embeds + emb = None + if isinstance(raw, list) and raw: + emb = raw[-1] + elif isinstance(raw, tuple) and raw: + inner = raw[0] + if isinstance(inner, list) and inner: + emb = inner[-1] + assert emb is not None, raw + assert emb.image is not None, emb + assert emb.image.url == links[1], emb.image.url + page = embed_footer_page(emb) + assert page == (2, 5), page + + +class Google(TestingWebScraping): + + def __init__(self): + super().__init__() + + @test + async def success(self) -> None: + self.cog.pxl.web_search = AsyncMock( + return_value=MockPxlResult( + success=True, + data={ + "results": [ + { + "title": "Test Result", + "url": "http://example.com", + "description": "A test description for search results", + } + ] + }, + ) + ) + await self.command(self.cog, self.base_context, text="test query") + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "Results for query" in self.base_context.result.message.embeds[0].title + ), self.base_context.result.message.embeds[0].title + + @test + async def api_error(self) -> None: + self.cog.pxl.web_search = AsyncMock( + return_value=MockPxlResult(success=False, error="Service unavailable") + ) + await self.command(self.cog, self.base_context, text="test query") + assert ( + self.base_context.result.message.embeds + ), self.base_context.result.message.embeds + assert ( + "error" in self.base_context.result.message.embeds[0].title.lower() + ), self.base_context.result.message.embeds[0].title + + @test + async def api_timeout(self) -> None: + from asyncio import TimeoutError as AsyncTimeout + + async def timeout_wait(awaitable, *args, **kwargs): + # `wait_for(coro, t)` evaluates `coro` before calling wait_for; if we raise + # immediately, that coroutine (e.g. from AsyncMock) is never awaited. + if inspect.iscoroutine(awaitable): + awaitable.close() + raise AsyncTimeout() + + with patch("killua.cogs.web_scraping.wait_for", side_effect=timeout_wait): + await self.command(self.cog, self.base_context, text="slow query") + assert ( + "too long" in self.base_context.result.message.content.lower() + ), self.base_context.result.message.content diff --git a/killua/tests/harnesses/__init__.py b/killua/tests/harnesses/__init__.py new file mode 100644 index 000000000..3a95404ac --- /dev/null +++ b/killua/tests/harnesses/__init__.py @@ -0,0 +1,97 @@ +"""Test harness helpers (paginator, spell use, poll/wyr, DM views, assertions).""" + +from .assertions import ( + assert_content_contains, + assert_content_equals, + assert_embed_title, + assert_inventory, + embed_at, + last_content, + reload_user, +) +from .context import respond_to_view +from .dm_view import patch_user_confirm_dm +from .interaction import MockComponentInteraction +from .member_dm import patch_member_rps_select, patch_member_trivia_select +from .paginator import embed_footer_page, press_paginator_button +from .poll_wyr import ( + ListenerFakeButton, + ListenerFakeRow, + build_poll_message, + build_wyr_message, + cast_vote, + encrypted_tail_on_button, + option_button_custom_id, +) +from .spell_use import ( + ATTACK_TIMEOUT_FRAGMENT, + DEFAULT_ATTACK_SPELL, + DEFENSE_SUCCESS_FRAGMENT, + MET_ERROR_FRAGMENT, + STEAL_TARGET_CARD, + assert_met_error, + assert_steal_blocked_by_defense, + assert_steal_succeeded, + embed0, + ensure_no_defense, + invoke_use, + make_target_member, + patch_random_choice, + respond_defense_with_spell, + run_attack_against_defender, + seed_channel_history, + setup_author_spell, + setup_met_view_spell, + setup_target_user, + target_member, + use_view_spell_paginator, +) +from .views import find_button, find_select, iter_view_items + +__all__ = [ + "ATTACK_TIMEOUT_FRAGMENT", + "DEFAULT_ATTACK_SPELL", + "DEFENSE_SUCCESS_FRAGMENT", + "ListenerFakeButton", + "ListenerFakeRow", + "MET_ERROR_FRAGMENT", + "MockComponentInteraction", + "STEAL_TARGET_CARD", + "assert_content_contains", + "assert_content_equals", + "assert_embed_title", + "assert_inventory", + "assert_met_error", + "assert_steal_blocked_by_defense", + "assert_steal_succeeded", + "build_poll_message", + "build_wyr_message", + "cast_vote", + "embed0", + "embed_at", + "embed_footer_page", + "encrypted_tail_on_button", + "ensure_no_defense", + "find_button", + "find_select", + "invoke_use", + "iter_view_items", + "last_content", + "make_target_member", + "option_button_custom_id", + "patch_member_rps_select", + "patch_member_trivia_select", + "patch_random_choice", + "patch_user_confirm_dm", + "press_paginator_button", + "reload_user", + "respond_defense_with_spell", + "respond_to_view", + "run_attack_against_defender", + "seed_channel_history", + "setup_author_spell", + "setup_met_view_spell", + "setup_target_user", + "target_member", + "use_view_spell_paginator", +] diff --git a/killua/tests/harnesses/assertions.py b/killua/tests/harnesses/assertions.py new file mode 100644 index 000000000..71bc4c512 --- /dev/null +++ b/killua/tests/harnesses/assertions.py @@ -0,0 +1,68 @@ +"""Shared assertion helpers for integration tests.""" + +from __future__ import annotations + +from typing import Any, Optional, Sequence, Tuple, Union + +from killua.utils.classes import User + + +def last_content(ctx: Any) -> str: + if not ctx.result or not ctx.result.message: + return "" + return ctx.result.message.content or "" + + +def embed_at(ctx: Any, index: int = -1): + raw = ctx.result.message.embeds if ctx.result else None + if raw is None: + return None + if isinstance(raw, list) and raw: + return raw[index] + if isinstance(raw, tuple) and raw: + inner = raw[0] + if isinstance(inner, list) and inner: + return inner[index] + return None + + +def assert_content_contains(ctx: Any, needle: str, *, msg: Optional[str] = None) -> None: + content = last_content(ctx) + assert needle in content, msg or f"expected {needle!r} in {content!r}" + + +def assert_content_equals(ctx: Any, expected: str) -> None: + assert last_content(ctx) == expected, ( + f"expected {expected!r}, got {last_content(ctx)!r}" + ) + + +def assert_embed_title(emb: Any, substring: str) -> None: + title = (emb.title or "") if emb else "" + assert substring in title, f"expected {substring!r} in embed title {title!r}" + + +async def reload_user(user_id: int) -> User: + User.cache.pop(user_id, None) + return await User.new(user_id) + + +async def assert_inventory( + user_id: int, + *, + has: Sequence[int] = (), + lacks: Sequence[int] = (), + count: Optional[dict[int, int]] = None, +) -> User: + user = await reload_user(user_id) + for cid in has: + assert user.has_any_card(cid), f"user {user_id} should have card {cid}" + for cid in lacks: + assert not user.has_any_card(cid), f"user {user_id} should lack card {cid}" + if count: + for cid, n in count.items(): + assert user.count_card(cid, including_fakes=False) == n, ( + f"user {user_id} card {cid}: expected count {n}, " + f"got {user.count_card(cid, including_fakes=False)}" + ) + return user diff --git a/killua/tests/harnesses/context.py b/killua/tests/harnesses/context.py new file mode 100644 index 000000000..f18ada38d --- /dev/null +++ b/killua/tests/harnesses/context.py @@ -0,0 +1,16 @@ +"""Context managers for test view / respond_to_view wiring.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any, Callable + + +@contextmanager +def respond_to_view(ctx: Any, callback: Callable): + prev = ctx.respond_to_view + ctx.respond_to_view = callback + try: + yield + finally: + ctx.respond_to_view = prev diff --git a/killua/tests/harnesses/dm_view.py b/killua/tests/harnesses/dm_view.py new file mode 100644 index 000000000..a895709e4 --- /dev/null +++ b/killua/tests/harnesses/dm_view.py @@ -0,0 +1,51 @@ +"""Helpers for ConfirmButton / LayoutView flows sent via User.send (DM).""" + +from __future__ import annotations + +from typing import Any + +from ..types import ArgumentInteraction, Message + + +def _find_button(item, custom_id: str): + if getattr(item, "custom_id", None) == custom_id: + return item + for child in getattr(item, "children", []) or []: + found = _find_button(child, custom_id) + if found: + return found + return None + + +async def patch_user_confirm_dm( + user: Any, + ctx: Any, + *, + invitee: Any = None, + confirm: bool = True, +) -> None: + """ + Patch ``user.send`` so ``ConfirmButton.wait`` auto-presses confirm or cancel. + + The invitee must match ``ConfirmButton.user_id`` (the DM recipient). + """ + actor = invitee if invitee is not None else user + + async def patched_send(*args, **kwargs): + view = kwargs.get("view") + content = kwargs.get("content") + msg = Message(author=actor, channel=ctx.channel, content=content or "") + msg.ctx = ctx + if view is not None: + + async def patched_wait(): + ix = ArgumentInteraction(ctx, user=actor, message=msg) + which = "confirm" if confirm else "cancel" + button = _find_button(view, which) + if button: + await button.callback(ix) + + view.wait = patched_wait + return msg + + user.send = patched_send diff --git a/killua/tests/harnesses/interaction.py b/killua/tests/harnesses/interaction.py new file mode 100644 index 000000000..78e1d65e6 --- /dev/null +++ b/killua/tests/harnesses/interaction.py @@ -0,0 +1,94 @@ +""" +Minimal interaction-shaped objects for Path B tests (cog `on_interaction` listeners). + +`BaseBot.send_message` treats instances with `_killua_test_send_as_interaction` like interactions. +""" + +from __future__ import annotations + +import discord +from discord import InteractionType +from typing import Any, Optional + + +class _MockFollowup: + def __init__(self, owner: "MockComponentInteraction") -> None: + self._owner = owner + + async def send(self, *args: Any, **kwargs: Any) -> Any: + await self._owner.context.send(*args, **kwargs) + return self._owner.context.result.message + + +class _MockInteractionResponse: + def __init__(self, owner: "MockComponentInteraction") -> None: + self._owner = owner + self._done = False + + def is_done(self) -> bool: + return self._done + + async def send_message(self, *args: Any, **kwargs: Any) -> Any: + if self._done: + raise RuntimeError("Interaction response already done") + self._done = True + await self._owner.context.send(*args, **kwargs) + self._owner._response_message = self._owner.context.result.message + return self._owner._response_message + + async def edit_message(self, *args: Any, **kwargs: Any) -> None: + if self._done: + raise RuntimeError("Interaction response already done") + self._done = True + msg = self._owner.message + embed = kwargs.get("embed") + view = kwargs.get("view") + if msg is None: + return + if embed is not None and hasattr(msg, "embeds"): + msg.embeds = [embed] + if view is not None and hasattr(msg, "components"): + msg.components = [ + type("_Row", (), {"children": list(view.children)})() + ] + + +class MockComponentInteraction: + """Component interaction with `context`, `data`, `user`, `message` for cog listeners.""" + + _killua_test_send_as_interaction = True + + def __init__( + self, + *, + context: Any, + custom_id: str, + user: Any, + message: Any, + client: Any, + ) -> None: + self.type = InteractionType.component + self.data: dict = {"custom_id": custom_id} + self.context = context + self.user = user + self.author = user + self.command = getattr(context, "command", None) + self.message = message + self.client = client + self.channel = context.channel + self.guild_id = getattr(context.guild, "id", None) + self.response = _MockInteractionResponse(self) + self.followup = _MockFollowup(self) + self._response_message: Optional[Any] = None + self.interaction = None + + async def send(self, *args: Any, **kwargs: Any) -> Any: + return await self.context.send(*args, **kwargs) + + def is_user_integration(self) -> bool: + return False + + async def original_response(self) -> Any: + if self._response_message is not None: + return self._response_message + return self.message diff --git a/killua/tests/harnesses/member_dm.py b/killua/tests/harnesses/member_dm.py new file mode 100644 index 000000000..6bb9b0aa4 --- /dev/null +++ b/killua/tests/harnesses/member_dm.py @@ -0,0 +1,104 @@ +"""Wire Member.send so DM views (e.g. RPS select) complete via real view.wait paths.""" + +from __future__ import annotations + +from typing import Any, Optional + +from killua.cogs.games import RpsSelect +from killua.utils.interactions import Select as KSelect + +from ..types import ArgumentInteraction, Message + + +def _find_rps_select(view: Any) -> Optional[Any]: + for child in getattr(view, "children", []) or []: + if isinstance(child, RpsSelect): + return child + return None + + +async def patch_member_rps_select( + member: Any, + ctx: Any, + *, + choice: int = 0, +) -> None: + """ + Patch ``member.send`` so ``_wait_for_dm_response`` receives real views with + ``value`` set by ``RpsSelect.callback`` (paper=0, rock=-1, scissors=1). + """ + + async def patched_send(*args, **kwargs): + view = kwargs.get("view") + content = kwargs.get("content") + embed = kwargs.get("embed") + msg = Message( + author=member, + channel=ctx.channel, + content=content or "", + embed=embed, + ) + msg.ctx = ctx + if view is not None: + view.user = member + + async def patched_wait(): + select = _find_rps_select(view) + if select is not None: + ix = ArgumentInteraction( + ctx, + user=member, + message=msg, + data={"values": [str(choice)]}, + ) + await select.callback(ix) + + view.wait = patched_wait + + return msg + + member.send = patched_send + + +async def patch_member_trivia_select( + member: Any, + ctx: Any, + *, + choice_index: int = 0, +) -> None: + """ + Patch ``member.send`` so trivia multiplayer ``_wait_for_dm_response`` completes + via real ``Select.callback`` (option index in the shuffled list). + """ + + async def patched_send(*args, **kwargs): + view = kwargs.get("view") + content = kwargs.get("content") + embed = kwargs.get("embed") + msg = Message( + author=member, + channel=ctx.channel, + content=content or "", + embed=embed, + ) + msg.ctx = ctx + if view is not None: + view.user = member + + async def patched_wait(): + for child in getattr(view, "children", []) or []: + if isinstance(child, KSelect): + ix = ArgumentInteraction( + ctx, + user=member, + message=msg, + data={"values": [str(choice_index)]}, + ) + await child.callback(ix) + break + + view.wait = patched_wait + + return msg + + member.send = patched_send diff --git a/killua/tests/harnesses/paginator.py b/killua/tests/harnesses/paginator.py new file mode 100644 index 000000000..b86712a86 --- /dev/null +++ b/killua/tests/harnesses/paginator.py @@ -0,0 +1,37 @@ +"""Helpers to drive killua.utils.paginator.Buttons in tests.""" + +from __future__ import annotations + +import re +from typing import Any, Optional, Tuple + +import discord + +from ..types import ArgumentInteraction +from .views import find_button + + +def embed_footer_page(embed: discord.Embed) -> Optional[Tuple[int, int]]: + """Parse 'Page n/m' from DefaultEmbed footer. Returns (page, max) or None.""" + foot = (embed.footer and embed.footer.text) or "" + m = re.search(r"Page\s+(\d+)/(\d+)", foot) + if not m: + return None + return int(m.group(1)), int(m.group(2)) + + +async def press_paginator_button( + view: Any, + custom_id: str, + *, + context: Any, + message: Optional[Any] = None, + user: Optional[Any] = None, +) -> None: + """Invoke a Paginator Buttons callback (custom_id: first, previous, next, last, delete).""" + btn = find_button(view, custom_id=custom_id) + assert btn is not None, f"no button custom_id={custom_id!r} on view" + msg = message or (context.result.message if context.result else None) + assert msg is not None, "paginator needs a message on context.result" + inter = ArgumentInteraction(context, user=user or context.author, message=msg) + await btn.callback(inter) diff --git a/killua/tests/harnesses/poll_wyr.py b/killua/tests/harnesses/poll_wyr.py new file mode 100644 index 000000000..40a85eaa7 --- /dev/null +++ b/killua/tests/harnesses/poll_wyr.py @@ -0,0 +1,154 @@ +"""Build poll/wyr message fixtures and drive Events vote interactions for tests.""" + +from __future__ import annotations + +from typing import Any, List, Optional, Sequence + +import discord + +from .interaction import MockComponentInteraction +from ..types import Bot + + +class ListenerFakeButton: + """Minimal row child for Events vote path (``to_dict``, ``custom_id``).""" + + def __init__(self, *, custom_id: str, label: str, style: int = 1) -> None: + self.custom_id = custom_id + self.label = label + self.style = style + + def to_dict(self) -> dict: + return { + "type": 2, + "style": int(self.style), + "label": self.label, + "custom_id": self.custom_id, + } + + +class ListenerFakeRow: + __slots__ = ("children",) + + def __init__(self, children: Sequence[ListenerFakeButton]) -> None: + self.children = list(children) + + +def _mention_lines(user_ids: Sequence[int]) -> str: + return "\n".join(f"<@{uid}>" for uid in user_ids) + + +def build_poll_message( + author_id: int, + *, + message_id: int = 99001, + option_index: int = 1, + option_count: int = 2, + visible_voter_ids: Optional[Sequence[int]] = None, + option_button_suffix: str = "", + close_suffix: str = "", +) -> Any: + visible_voter_ids = list(visible_voter_ids or []) + enc_author = Bot._encrypt(author_id, smallest=False) + sty = int(discord.ButtonStyle.blurple) + emb = discord.Embed(title="Poll", description="Question?", color=0x3E4A78) + for pos in range(1, option_count + 1): + voters = visible_voter_ids if pos == option_index else [] + emb.add_field( + name=f"{pos}) Option {pos} `[{len(voters)} votes]`", + value=_mention_lines(voters) if voters else "—", + inline=False, + ) + + buttons: List[ListenerFakeButton] = [] + for pos in range(1, option_count + 1): + suffix = option_button_suffix if pos == option_index else "" + buttons.append( + ListenerFakeButton( + custom_id=f"poll:opt-{pos}:{suffix}", + label=str(pos), + style=sty, + ) + ) + buttons.append( + ListenerFakeButton( + custom_id=f"poll:close:{enc_author}:{close_suffix}", + label="Close", + style=sty, + ) + ) + + class PollMessage: + id = message_id + embeds = [emb] + components = [ListenerFakeRow(buttons)] + + return PollMessage() + + +def build_wyr_message( + *, + message_id: int = 99002, + side: str = "b", + visible_voter_ids: Optional[Sequence[int]] = None, + option_button_suffix: str = "", +) -> Any: + visible_voter_ids = list(visible_voter_ids or []) + sty = int(discord.ButtonStyle.blurple) + emb = discord.Embed(title="Would you rather...", color=0x3E4A78) + for label, key in (("A", "a"), ("B", "b")): + voters = visible_voter_ids if key == side else [] + emb.add_field( + name=f"{label}) choice `[{len(voters)} people]`", + value=_mention_lines(voters) if voters else "—", + inline=False, + ) + + suffix_a = option_button_suffix if side == "a" else "" + suffix_b = option_button_suffix if side == "b" else "" + row = ListenerFakeRow( + [ + ListenerFakeButton( + custom_id=f"wyr:opt-a:{suffix_a}", label="A", style=sty + ), + ListenerFakeButton( + custom_id=f"wyr:opt-b:{suffix_b}", label="B", style=sty + ), + ] + ) + + class WyrMessage: + id = message_id + embeds = [emb] + components = [row] + + return WyrMessage() + + +async def cast_vote( + events: Any, + *, + context: Any, + message: Any, + voter: Any, + custom_id: str, +) -> MockComponentInteraction: + ix = MockComponentInteraction( + context=context, + custom_id=custom_id, + user=voter, + message=message, + client=Bot, + ) + await events.on_interaction(ix) + return ix + + +def option_button_custom_id(message: Any, option_index: int = 1) -> str: + return message.components[0].children[option_index - 1].custom_id + + +def encrypted_tail_on_button(custom_id: str, prefix: str) -> str: + if not custom_id.startswith(prefix): + return "" + return custom_id[len(prefix) :] diff --git a/killua/tests/harnesses/spell_use.py b/killua/tests/harnesses/spell_use.py new file mode 100644 index 000000000..e9191aee0 --- /dev/null +++ b/killua/tests/harnesses/spell_use.py @@ -0,0 +1,295 @@ +"""Helpers for per-spell ``cards use`` integration tests.""" + +from __future__ import annotations + +import math +from contextlib import contextmanager +from typing import Any, Optional, Sequence, Tuple, Union +from unittest.mock import patch + +from killua.utils.interactions import Select as KSelect +from killua.utils.classes import User +from killua.utils.classes.card import Card +from killua.static.constants import DEF_SPELLS, VIEW_DEF_SPELLS +from killua.utils.paginator import Buttons + +from ..types import ArgumentInteraction, DiscordMember +from .assertions import ( + assert_content_contains, + assert_inventory, + embed_at, + last_content, + reload_user, +) + +SPELL_IDS_WITH_EXEC = [ + 1001, 1002, 1007, 1008, 1010, 1011, 1015, 1018, 1020, 1021, 1024, 1026, + 1028, 1029, 1031, 1032, 1035, 1036, 1038, +] +DEFENSE_SPELL_IDS = list(DEF_SPELLS) + list(VIEW_DEF_SPELLS) +DEFAULT_ATTACK_SPELL = 1021 +STEAL_TARGET_CARD = 50 +MET_ERROR_FRAGMENT = "haven't met this user yet" +DEFENSE_SUCCESS_FRAGMENT = "successfully defended" +ATTACK_TIMEOUT_FRAGMENT = "attack goes through" + + +async def setup_author_spell( + author_id: int, + spell_id: int, + *, + extra_fs: Optional[Sequence[int]] = None, + met_ids: Optional[Sequence[int]] = None, +) -> User: + user = await User.new(author_id) + await user.nuke_cards("all") + await user.add_card(spell_id) + for cid in extra_fs or []: + await user.add_card(cid) + for mid in met_ids or []: + await user.add_met_user(mid) + return user + + +async def setup_target_user( + target_id: int, + *, + fs_cards: Optional[Sequence] = None, + rs_cards: Optional[Sequence] = None, + defense_ids: Optional[Sequence[int]] = None, + effects: Optional[dict] = None, + met_attacker: bool = True, + attacker_id: Optional[int] = None, +) -> User: + user = await User.new(target_id) + await user.nuke_cards("all") + + async def _add_slot(cid, *, fake=False, clone=False): + await user.add_card(int(cid), fake=bool(fake), clone=bool(clone)) + + for cid in fs_cards or []: + fake, clone = False, False + if isinstance(cid, tuple): + cid, fake, clone = cid[0], cid[1], cid[2] if len(cid) > 2 else False + await _add_slot(cid, fake=fake, clone=clone) + for cid in rs_cards or []: + fake, clone = False, False + if isinstance(cid, tuple): + cid, fake, clone = cid[0], cid[1], cid[2] if len(cid) > 2 else False + else: + cid = int(cid) + await _add_slot(cid, fake=fake, clone=clone) + for did in defense_ids or []: + if not user.has_any_card(did): + await user.add_card(did) + if effects: + for key, val in effects.items(): + await user.add_effect(key, val) + if met_attacker and attacker_id is not None: + await user.add_met_user(attacker_id) + return user + + +def ensure_no_defense(user: User) -> None: + for did in DEF_SPELLS + VIEW_DEF_SPELLS: + assert not user.has_any_card(did), f"target still has defense card {did}" + assert not user.has_effect("1026")[0], "target has 1026 protection active" + + +def _is_member_target(target: Any) -> bool: + return target is not None and hasattr(target, "id") and hasattr(target, "display_name") + + +async def invoke_use( + testing: Any, + card_id: Union[int, str], + *, + target: Any = None, + args: Any = None, +) -> None: + item = str(card_id) + cog, ctx = testing.cog, testing.base_context + if target is None and args is None: + await testing.command(cog, ctx, item) + elif args is None: + if _is_member_target(target): + await testing.command(cog, ctx, item, target) + else: + await testing.command(cog, ctx, item, target=target) + elif _is_member_target(target): + await testing.command(cog, ctx, item, target, args=args) + else: + await testing.command(cog, ctx, item, target=target, args=args) + + +def make_target_member(testing: Any, target_id: int) -> DiscordMember: + member = DiscordMember( + id=target_id, + username=f"Target{target_id}", + mutual_guilds=[object()], + ) + testing.base_guild.members = [testing.base_author, member] + return member + + +def target_member(testing: Any, offset: int) -> Tuple[DiscordMember, int]: + target_id = testing.base_author.id + offset + return make_target_member(testing, target_id), target_id + + +async def setup_met_view_spell( + testing: Any, + spell_id: int, + offset: int, + *, + fs_cards: Optional[Sequence] = None, +) -> Tuple[DiscordMember, int]: + member, target_id = target_member(testing, offset) + await setup_author_spell( + testing.base_author.id, spell_id, met_ids=[target_id] + ) + await setup_target_user(target_id, fs_cards=fs_cards or [1011]) + return member, target_id + + +async def use_view_spell_paginator( + testing: Any, + spell_id: int, + target: DiscordMember, +) -> None: + testing.base_context.timeout_view = True + await invoke_use(testing, spell_id, target=target) + assert isinstance(testing.base_context.current_view, Buttons) + + +def assert_met_error(ctx: Any) -> None: + assert MET_ERROR_FRAGMENT in last_content(ctx).lower() + + +async def assert_steal_succeeded( + ctx: Any, + author_id: int, + target_id: int, + card_id: int, + *, + message_fragment: str = "stole", +) -> None: + assert_content_contains(ctx, message_fragment) + await assert_inventory(author_id, has=[card_id]) + await assert_inventory(target_id, lacks=[card_id]) + + +async def assert_steal_blocked_by_defense( + ctx: Any, + author_id: int, + target_id: int, + card_id: int, +) -> None: + assert_content_contains(ctx, DEFENSE_SUCCESS_FRAGMENT) + await assert_inventory(target_id, has=[card_id]) + await assert_inventory(author_id, lacks=[card_id]) + + +async def respond_defense_with_spell(ctx: Any, spell_id: int) -> None: + view = ctx.current_view + if view is None: + return + for child in view.children: + if isinstance(child, KSelect): + await child.callback( + ArgumentInteraction( + ctx, + user=getattr(ctx.current_view, "user_id", ctx.author), + data={"values": [str(spell_id)]}, + ) + ) + break + if view is not None: + view.stop() + + +@contextmanager +def patch_random_choice(return_value: Any): + with patch("killua.static.cards.random.choice", return_value=return_value): + yield + + +def seed_channel_history(ctx: Any, authors: Sequence[Any]): + class _HistMsg: + def __init__(self, author: Any): + self.author = author + + messages = [_HistMsg(a) for a in authors] + + async def _history(limit=20): + for m in messages[:limit]: + yield m + + return patch.object(ctx.channel, "history", _history) + + +async def run_attack_against_defender( + testing: Any, + *, + defense_id: int, + attacker_spell: int = DEFAULT_ATTACK_SPELL, + stolen_card: int = STEAL_TARGET_CARD, + use_defense: bool = True, + attacker_in_met: bool = True, + patch_attacker_range: Optional[str] = None, +) -> Tuple[DiscordMember, int]: + target_id = testing.base_author.id + 50_000 + target_member_obj = make_target_member(testing, target_id) + await setup_author_spell( + testing.base_author.id, + attacker_spell, + met_ids=[target_id] if attacker_in_met else [], + ) + await setup_target_user( + target_id, + rs_cards=[(stolen_card, False, False)], + defense_ids=[defense_id] if defense_id in DEF_SPELLS else None, + fs_cards=[(defense_id, False, False)] if defense_id in VIEW_DEF_SPELLS else None, + met_attacker=attacker_in_met, + attacker_id=testing.base_author.id, + ) + + prev_rtv = testing.base_context.respond_to_view + if use_defense: + + async def _def(ctx): + await respond_defense_with_spell(ctx, defense_id) + + testing.base_context.respond_to_view = _def + else: + testing.base_context.respond_to_view = prev_rtv + + patches = [] + if patch_attacker_range is not None: + original_init = Card.__init__ + + def _patched_init(self, name_or_id, *a, **kw): + original_init(self, name_or_id, *a, **kw) + if getattr(self, "id", None) == attacker_spell: + self.range = patch_attacker_range + + patches.append(patch.object(Card, "__init__", _patched_init)) + + try: + for p in patches: + p.start() + await invoke_use( + testing, + attacker_spell, + target=target_member_obj, + args=stolen_card, + ) + finally: + for p in patches: + p.stop() + testing.base_context.respond_to_view = prev_rtv + return target_member_obj, target_id + + +# Back-compat aliases +embed0 = embed_at diff --git a/killua/tests/harnesses/views.py b/killua/tests/harnesses/views.py new file mode 100644 index 000000000..92e45157b --- /dev/null +++ b/killua/tests/harnesses/views.py @@ -0,0 +1,47 @@ +"""Traverse discord.ui views and locate buttons/selects for tests.""" + +from __future__ import annotations + +from typing import Any, Iterator, Optional + +import discord + + +def iter_view_items(view: Any) -> Iterator[Any]: + """Yield UI components under a View (unwraps ActionRow, recurses LayoutView containers).""" + if view is None: + return + for item in getattr(view, "children", None) or []: + if isinstance(item, discord.ui.ActionRow): + for child in item.children: + yield child + elif getattr(item, "children", None): + yield from iter_view_items(item) + else: + yield item + + +def find_button( + view: Any, + *, + custom_id: Optional[str] = None, + label: Optional[str] = None, +) -> Optional[Any]: + for item in iter_view_items(view): + if not isinstance(item, discord.ui.Button): + continue + if custom_id is not None and getattr(item, "custom_id", None) != custom_id: + continue + if label is not None and getattr(item, "label", None) != label: + continue + return item + return None + + +def find_select(view: Any, *, custom_id: Optional[str] = None) -> Optional[Any]: + for item in iter_view_items(view): + if isinstance(item, discord.ui.Select) and ( + custom_id is None or getattr(item, "custom_id", None) == custom_id + ): + return item + return None diff --git a/killua/tests/testing.py b/killua/tests/testing.py index 09bd75d2e..66d07073b 100644 --- a/killua/tests/testing.py +++ b/killua/tests/testing.py @@ -7,16 +7,42 @@ import logging from typing import TYPE_CHECKING, List, Coroutine, Optional +from . import config + if TYPE_CHECKING: from .types import Context, TestResult +def _test_class_command_name(cls) -> str: + """Discord command name for this test class (defaults to class name).""" + return getattr(cls, "command_name", None) or cls.__name__.lower() + + +def collect_test_classes(group_cls: type) -> list: + """Return leaf test classes registered under a group (depth-first subclass walk).""" + found: list = [] + + def walk(base: type) -> None: + for cls in base.__subclasses__(): + if cls.__subclasses__(): + walk(cls) + else: + found.append(cls) + + walk(group_cls) + return found + + class Testing: """Modifies several discord classes to be suitable in a testing environment""" + # Set True on cog group classes (TestingCards, TestingGames, …) so leaf command + # classes fail fast when no matching discord command exists. + requires_command: bool = False + def __new__( cls, *args, **kwargs - ): # This prevents this class from direct instatioation + ): # This prevents this class from direct instantiation if cls is Testing: raise TypeError(f"only children of '{cls.__name__}' may be instantiated") return object.__new__(cls, *args, **kwargs) @@ -45,17 +71,35 @@ def __init__(self, cog: Cog): # be overwriting that method anyways self.result: TestResult = TestResult() self.cog: Cog = cog(Bot) + if self._should_bind_command(): + cmd = self.command + if cmd is None: + want = _test_class_command_name(self.__class__) + raise ValueError( + f"No discord command named {want!r} on cog {cog.__name__!r} " + f"(test class {self.__class__.__name__})" + ) + + @classmethod + def _should_bind_command(cls) -> bool: + if not getattr(cls, "requires_command", False): + return False + if cls.__name__.startswith("Testing"): + return False + for name in dir(cls): + if isinstance(getattr(cls, name, None), test): + return True + return False @property def all_tests(self) -> List[Testing]: """Automatically checks what functions are test based on their name and the overlap with the Cog method names""" cog_methods = [] for cmd in [(command.name, command) for command in self.cog.get_commands()]: + cog_methods.append(cmd) if hasattr(cmd[1], "walk_commands") and cmd[1].walk_commands(): for child in cmd[1].walk_commands(): cog_methods.append((child.name, child)) - else: - cog_methods.append(cmd) command_classes: List[Testing] = [] @@ -71,15 +115,16 @@ def all_tests(self) -> List[Testing]: return [ cls for cls in command_classes - if cls.__name__.lower() in [n for n, _ in cog_methods] + if _test_class_command_name(cls) in [n for n, _ in cog_methods] ] @property def command(self) -> Coroutine: """The command that is being tested""" + want = _test_class_command_name(self.__class__) for command in self.cog.walk_commands(): if isinstance(command, Command): - if command.name.lower() == self.__class__.__name__.lower(): + if command.name.lower() == want.lower(): return command async def run_tests(self, only_command: Optional[str] = None) -> TestResult: @@ -87,10 +132,12 @@ async def run_tests(self, only_command: Optional[str] = None) -> TestResult: for test in self.all_tests: command = test() - if only_command and command.__class__.__name__.lower() != only_command: + if only_command and _test_class_command_name(command.__class__) != only_command: continue # Skip if the command is not the one we want to test await command.test_command() + cmd_name = _test_class_command_name(command.__class__) + self.result.by_command.setdefault(cmd_name, []).extend(command.result.records) self.result.add_result(command.result) # await self.cog.client.session.close() @@ -98,18 +145,31 @@ async def run_tests(self, only_command: Optional[str] = None) -> TestResult: async def test_command(self) -> None: """Runs all tests of a command""" + from .fixtures import reset_test_fixtures + reset_test_fixtures() for method in test.tests(self): await method(self) @classmethod async def press_confirm(cls, context: Context): - """Presses the confirm button of a ConfirmView""" + """Presses the confirm button of a ConfirmView or ConfirmButton""" + from .harnesses.views import find_button + from .types import ArgumentInteraction + + button = find_button(context.current_view, custom_id="confirm") + if button: + await button.callback(ArgumentInteraction(context)) + + @classmethod + async def press_cancel(cls, context: Context): + """Presses the cancel button of a ConfirmView or ConfirmButton.""" + from .harnesses.views import find_button from .types import ArgumentInteraction - for child in context.current_view.children: - if child.custom_id == "confirm": - await child.callback(ArgumentInteraction(context)) + button = find_button(context.current_view, custom_id="cancel") + if button: + await button.callback(ArgumentInteraction(context)) class test(object): @@ -121,15 +181,17 @@ async def __call__(self, obj: Testing, *args, **kwargs): from .types import Result, ResultData try: + cmd_label = _test_class_command_name(obj.__class__) logging.debug( - f"Running test {self._method.__name__} of command {obj.__class__.__name__}" + f"Running test {self._method.__name__} of command {cmd_label}" ) await self._method(obj, *args, **kwargs) logging.debug("successfully passed test") obj.result.completed_test(self._method, Result.passed) except Exception as e: _, _, var = sys.exc_info() - traceback.print_tb(var) + if not config.SUPPRESS_TEST_TRACEBACKS: + traceback.print_tb(var) tb_info = traceback.extract_tb(var) filename, line_number, _, text = tb_info[-1] @@ -137,14 +199,14 @@ async def __call__(self, obj: Testing, *args, **kwargs): parsed_text = text.split(",")[0] logging.error( - f'{filename}:{line_number} test "{self._method.__name__}" of command "{obj.__class__.__name__.lower()}" failed at \n{parsed_text} \nwith actual result \n"{e}"' + f'{filename}:{line_number} test "{self._method.__name__}" of command "{cmd_label}" failed at \n{parsed_text} \nwith actual result \n"{e}"' ) obj.result.completed_test( self._method, Result.failed, result_data=ResultData(error=e) ) else: logging.critical( - f'{filename}:{line_number} test "{self._method.__name__}" of command "{obj.__class__.__name__.lower()}" raised the the following exception in the statement {text}: \n"{e}"' + f'{filename}:{line_number} test "{self._method.__name__}" of command "{cmd_label}" raised the the following exception in the statement {text}: \n"{e}"' ) obj.result.completed_test( self._method, Result.errored, ResultData(error=e) diff --git a/killua/tests/types/asset.py b/killua/tests/types/asset.py index 9559dbdee..8d8d7f138 100644 --- a/killua/tests/types/asset.py +++ b/killua/tests/types/asset.py @@ -12,3 +12,6 @@ def __init__(self, url: str = None, **kwargs): @property def url(self) -> str: return "https://images.com/image.png" if self._url is None else self._url + + def is_animated(self) -> bool: + return False diff --git a/killua/tests/types/bot.py b/killua/tests/types/bot.py index 1dd32ff49..07743de33 100644 --- a/killua/tests/types/bot.py +++ b/killua/tests/types/bot.py @@ -1,5 +1,6 @@ import sys, os from aiohttp import ClientSession +import discord # This is a necessary hacky fix for importing issues SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -11,7 +12,7 @@ from .channel import TestingTextChannel from .user import TestingUser -from typing import Any, Optional, Callable +from typing import Any, Optional, Callable, Tuple from asyncio import get_event_loop, TimeoutError, sleep @@ -84,10 +85,57 @@ async def resolve(self, event: str, /, *args: Any) -> None: async def send_message(self, messageable: Messageable, *args, **kwargs) -> Message: """We do not want a tip sent which would ruin the test checks so this is overwritten""" + content = kwargs.pop("content", None) + if args and isinstance(args[0], str): + content = args[0] + args = args[1:] return await messageable.send( - content=kwargs.pop("content", None), *args, **kwargs + content=content, *args, **kwargs ) + async def find_dominant_color(self, url: str) -> int: + return 0x3E4A78 + + def sha256_for_api(self, endpoint: str, expires_in_seconds: int) -> Tuple[str, str]: + return ("fake_token", "9999999999") + + def api_url(self, *, to_fetch=False, is_for_cards=False): + return "http://localhost:6060" + + async def make_embed_from_api( + self, + image_url: str, + embed: discord.Embed, + expire_in: int = 60 * 60 * 24 * 7, + no_token: bool = False, + thumbnail: bool = False, + force_fetch: bool = False, + ) -> Tuple[discord.Embed, Optional[discord.File]]: + if thumbnail: + embed.set_thumbnail(url=image_url) + else: + embed.set_image(url=image_url) + return embed, None + + def convert_to_timestamp(self, snowflake_id: int) -> str: + return f"> 22) / 1000 + 1420070400)}:f>" + + def get_lootbox_from_name(self, name: str): + from ...static.constants import LOOTBOXES + for lb_id, lb in LOOTBOXES.items(): + if lb["name"].lower() == name.lower(): + return lb_id + return None + + async def fetch_user(self, user_id: int): + return TestingUser(id=user_id) + + async def _dm_check(self, user) -> bool: + return True + + def is_user_installed(self, ctx) -> bool: + return False + async def setup_hook(self) -> None: self.session = ClientSession() diff --git a/killua/tests/types/channel.py b/killua/tests/types/channel.py index 60a728c31..8f0b0dcb7 100644 --- a/killua/tests/types/channel.py +++ b/killua/tests/types/channel.py @@ -70,6 +70,19 @@ async def send(self, content: str, *args, **kwargs) -> None: self.ctx.current_view.wait = partial(self.ctx.respond_to_view, self.ctx) return message - def permissions_for(self, member: Guild.Member) -> ui.Permissions: + async def typing(self): + pass + + async def fetch_message(self, message_id: int): + return Message(author=None, channel=self) + + def permissions_for(self, member: Guild.Member): """Gets the permissions for a member""" - return self._has_permission + from .permissions import Permissions + if isinstance(self._has_permission, Permissions): + return self._has_permission + return Permissions( + send_messages=self._has_permission, + read_messages=self._has_permission, + manage_guild=self._has_permission, + ) diff --git a/killua/tests/types/context.py b/killua/tests/types/context.py index 57eef8632..d8a7776b0 100644 --- a/killua/tests/types/context.py +++ b/killua/tests/types/context.py @@ -24,6 +24,9 @@ def __init__(self, **kwargs): self.channel: TextChannel = self.message.channel self.author: Member = self.message.author self.command: Union[Command, None] = None + self.invoked_subcommand = kwargs.pop("invoked_subcommand", None) + self.interaction = None + self.guild = self.message.channel.guild self.current_view: Union[View, None] = None self.message.channel.ctx: TestingContext = self @@ -64,6 +67,10 @@ async def send(self, content: str = None, *args, **kwargs) -> Message: message.ctx = self return message + async def defer(self, *args, **kwargs) -> None: + """Defers the interaction response""" + ... + async def invoke(self, command: str, *args, **kwargs) -> None: """Invokes a command""" ... diff --git a/killua/tests/types/guild.py b/killua/tests/types/guild.py index 93981cdee..cb400bc94 100644 --- a/killua/tests/types/guild.py +++ b/killua/tests/types/guild.py @@ -3,8 +3,9 @@ from discord import Guild, Asset from .utils import get_random_discord_id, random_name +from .asset import Asset as TestingAsset -from typing import Union +from typing import Union, List class TestingGuild: @@ -16,6 +17,7 @@ def __init__(self, **kwargs): self.id: int = kwargs.pop("id", get_random_discord_id()) self.name: str = kwargs.pop("name", random_name()) self.owner_id: int = kwargs.pop("owner_id", get_random_discord_id()) + self.owner = kwargs.pop("owner", None) self.region: str = kwargs.pop("region", "us") self.afk_channel_id: int = kwargs.pop("afk_channel_id", None) self.afk_timeout: int = kwargs.pop("afk_timeout", 1) @@ -41,3 +43,31 @@ def __init__(self, **kwargs): self.stickers: list = kwargs.pop("stickers", []) self.stage_instances: list = kwargs.pop("stage_instances", []) self.guild_scheduled_events: list = kwargs.pop("guild_sceduled_events", []) + self.icon = kwargs.pop("icon", TestingAsset()) + self.member_count: int = kwargs.pop("member_count", 10) + self.members: List = kwargs.pop("members", []) + self.chunked: bool = True + # Ban list entries: objects with .user (TestingUser-like) for unban by name + self._ban_list: List = kwargs.pop("_ban_list", []) + + async def ban(self, user, **kwargs) -> None: + """No-op; tests may assert call via patch.""" + + async def unban(self, user) -> None: + """No-op; tests may patch.""" + + async def bans(self, limit: int = 100): + for entry in self._ban_list[:limit]: + yield entry + + def get_member(self, user_id: int): + for m in self.members: + if m.id == user_id: + return m + return None + + def get_member_named(self, name: str): + return None + + async def chunk(self): + pass diff --git a/killua/tests/types/interaction.py b/killua/tests/types/interaction.py index d50b7890b..5e840b3bd 100644 --- a/killua/tests/types/interaction.py +++ b/killua/tests/types/interaction.py @@ -3,7 +3,7 @@ from discord import Interaction from discord.ext.commands import Context -from typing import Literal +from typing import Literal, Optional, Any from .utils import get_random_discord_id, random_name @@ -28,7 +28,9 @@ async def edit_message(self, *args, **kwargs) -> None: if self._is_done: raise Exception("Interaction can only be responded to once.") self._is_done = True - # await self.interaction.message.edit(*args, **kwargs) + msg = getattr(self.interaction, "message", None) + if msg is not None and hasattr(msg, "edit"): + await msg.edit(*args, **kwargs) async def send_modal(self, *args, **kwargs) -> None: if self._is_done: @@ -41,12 +43,27 @@ def is_done(self) -> bool: class ArgumentInteraction: - """This classes purpose is purely to be supplied to callbacks of message interactions""" - - def __init__(self, context: Context, **kwargs): - self.__dict__ = kwargs + """Supplied to View item callbacks (buttons/selects). Optional user/message match Discord interaction shape.""" + + def __init__( + self, + context: Context, + *, + user: Any = None, + message: Any = None, + **kwargs: Any, + ) -> None: self.context = context - self.user = context.author + self.user = user if user is not None else context.author + if message is not None: + self.message = message + elif context.result is not None and getattr(context.result, "message", None): + self.message = context.result.message + else: + self.message = None + self.data = kwargs.pop("data", {}) + for k, v in kwargs.items(): + setattr(self, k, v) self.response = ArgumentResponseInteraction(self) diff --git a/killua/tests/types/member.py b/killua/tests/types/member.py index 856f0161f..dbd69f260 100644 --- a/killua/tests/types/member.py +++ b/killua/tests/types/member.py @@ -8,6 +8,8 @@ from .utils import get_random_discord_id, random_date from .user import TestingUser as User +from .role import TestingRole +from .permissions import Permissions class TestingMember(User): @@ -26,11 +28,22 @@ def __init__(self, **kwargs): "communication_disabled_until", "" ) self.premium_since: Union[datetime, None] = kwargs.pop("premium_since", None) + self.top_role = kwargs.pop("top_role", TestingRole(position=1)) + self.guild_permissions = kwargs.pop("guild_permissions", Permissions(administrator=True)) + self._timed_out = kwargs.pop("timed_out", False) + self.premium_subscribers = [] @property def display_name(self) -> str: return self.nick or self.username + def is_timed_out(self) -> bool: + return self._timed_out + + async def ban(self, **kwargs): ... + async def kick(self, **kwargs): ... + async def timeout(self, *args, **kwargs): ... + def __random_roles(self) -> List[Snowflake]: """Creates a random list of roles a user has""" return [get_random_discord_id() for _ in range(randint(0, 10))] diff --git a/killua/tests/types/message.py b/killua/tests/types/message.py index b6b28c13c..4549dedf1 100644 --- a/killua/tests/types/message.py +++ b/killua/tests/types/message.py @@ -32,37 +32,44 @@ def __init__(self, author: Member, channel: TextChannel, **kwargs): self.edited_timestamp = kwargs.pop("edited_timestamp", None) self.tts = (kwargs.pop("tts", False),) self.mention_everyone = (kwargs.pop("mention_everyone", False),) - self.mentions = (kwargs.pop("mentions", []),) - self.mention_roles = (kwargs.pop("mention_roles", []),) - self.attachments = (kwargs.pop("attachments", []),) + self.mentions = list(kwargs.pop("mentions", [])) + self.mention_roles = list(kwargs.pop("mention_roles", [])) + # Discord API shape: list of attachment-like objects with .url + self.attachments = list(kwargs.pop("attachments", [])) + self.file = kwargs.pop("file", None) + self.files = kwargs.pop("files", None) self.embeds = (kwargs.get("embeds", []),) self.pinned = (kwargs.pop("pinned", False),) self.type = kwargs.pop( "type", 0 ) # https://discord.com/developers/docs/resources/channel#message-object-message-types - self.referencing = kwargs.pop("reference", None) - self.reference = self + self.reference = kwargs.pop("reference", None) if "embed" in kwargs: self.embeds = [kwargs.pop("embed")] async def edit(self, **kwargs) -> None: """Edits the message""" - self.__dict__.update(kwargs) # Changes the properties defined in the kwargs self.edited = True - self.ctx.current_view = kwargs.pop("view", None) - if "embed" in kwargs: - self.embeds.append(kwargs["embed"]) - + view = kwargs.pop("view", None) + content = kwargs.pop("content", None) + embed = kwargs.pop("embed", None) + if content is not None: + self.content = content + if embed is not None: + self.embeds = [embed] + self.__dict__.update(kwargs) + self.ctx.current_view = view if self.ctx.current_view: if not len( - [c for c in self.ctx.current_view.children if c.disabled] + [c for c in self.ctx.current_view.children if getattr(c, "disabled", False)] ) == len(self.ctx.current_view.children): self.ctx.current_view.wait = partial(self.ctx.respond_to_view, self.ctx) else: return - self.ctx.result.message = self + if self.ctx.result is not None and self.ctx.result.message is self: + self.ctx.result.message = self # print( # "Edited view: ", self.view, # "Edited embeds: ", self.embeds, diff --git a/killua/tests/types/role.py b/killua/tests/types/role.py index dc0fbaea7..442c508ba 100644 --- a/killua/tests/types/role.py +++ b/killua/tests/types/role.py @@ -25,3 +25,15 @@ def __init__(self, **kwargs): @property def permissions(self) -> int: return Permissions(self._permissions) + + def __lt__(self, other): + return self.position < other.position + + def __gt__(self, other): + return self.position > other.position + + def __le__(self, other): + return self.position <= other.position + + def __ge__(self, other): + return self.position >= other.position diff --git a/killua/tests/types/testing_results.py b/killua/tests/types/testing_results.py index 88d6f3654..0c814e548 100644 --- a/killua/tests/types/testing_results.py +++ b/killua/tests/types/testing_results.py @@ -3,7 +3,7 @@ from discord.ext.commands import Command from enum import Enum -from typing import Any +from typing import Any, Dict, List, Optional class Result(Enum): @@ -32,10 +32,24 @@ def __init__(self): self.passed = [] self.failed = [] self.errored = [] + # One entry per @test run, in order, for this command class instance only. + self.records: List[Dict[str, Any]] = [] + # Populated on the per-cog Testing aggregate: command name -> list of record dicts. + self.by_command: Dict[str, List[Dict[str, Any]]] = {} def completed_test( self, command: Command, result: Result, result_data: ResultData = None ) -> None: + err: Optional[str] = None + if result_data is not None and result_data.error is not None: + err = str(result_data.error) + self.records.append( + { + "name": getattr(command, "__name__", str(command)), + "result": result == Result.passed, + "error": err, + } + ) if result == Result.passed: self.passed.append(command) elif result == Result.failed: diff --git a/killua/tests/types/user.py b/killua/tests/types/user.py index 58d101855..fe179609d 100644 --- a/killua/tests/types/user.py +++ b/killua/tests/types/user.py @@ -4,6 +4,14 @@ from .asset import Asset from .utils import get_random_discord_id, random_name +from .permissions import Permissions +from .role import TestingRole + + +class PublicFlags: + """Minimal stand-in for discord.PublicUserFlags.""" + def __iter__(self): + return iter([]) class TestingUser: @@ -19,14 +27,46 @@ def __init__(self, **kwargs): self.avatar = Asset(kwargs.pop("avatar")) if "avatar" in kwargs else Asset() self.bot = kwargs.pop("bot", False) self.premium_type = kwargs.pop("premium_type", 0) + self.banner = kwargs.pop("banner", None) + self.public_flags = kwargs.pop("public_flags", PublicFlags()) + # Bot-style checks in guild commands (ban_members, view_audit_log, etc.) + self.guild_permissions = kwargs.pop( + "guild_permissions", + Permissions( + ban_members=True, + kick_members=True, + view_audit_log=True, + moderate_members=True, + administrator=True, + ), + ) + self.top_role = kwargs.pop("top_role", TestingRole(position=999)) + # RPS / trivia use `if not user.mutual_guilds` for non-bot opponents (discord.User) + self.mutual_guilds = kwargs.pop("mutual_guilds", [object()]) + + @property + def display_name(self) -> str: + return self.username + + @property + def display_avatar(self) -> "Asset": + return self.avatar @property def mention(self) -> str: return "<@{}>".format(self.id) def __eq__(self, other: "TestingUser") -> bool: + if not hasattr(other, "id"): + return NotImplemented return self.id == other.id + def __str__(self) -> str: + return self.username + + async def send(self, *args, **kwargs) -> None: + pass + def __random_discriminator(self) -> str: """Creates a random discriminator""" return ( diff --git a/killua/utils/classes/card.py b/killua/utils/classes/card.py index 65758a2be..ca520e561 100644 --- a/killua/utils/classes/card.py +++ b/killua/utils/classes/card.py @@ -108,7 +108,7 @@ def __init__(self, name_or_id: Union[str, int], ctx: Optional[commands.Context] self.ctx = ctx return - if not cards_id: + if cards_id is None: raise CardNotFound raw = next(c for c in self.raw if c["id"] == cards_id) @@ -266,7 +266,7 @@ def _permission_check(self, ctx: commands.Context, member: discord.Member) -> No perms = ctx.channel.permissions_for(member) if not perms.send_messages or not perms.read_messages: raise CheckFailure( - f"You can only attack a user in a channel they have read and write permissions to which isn't the case with {self.Member.display_name}" + f"You can only attack a user in a channel they have read and write permissions to which isn't the case with {member.display_name}" ) def _has_cards_check( diff --git a/killua/utils/test_db.py b/killua/utils/test_db.py index 952a6ecb1..e3b1ca8a1 100644 --- a/killua/utils/test_db.py +++ b/killua/utils/test_db.py @@ -1,4 +1,35 @@ from typing import Optional, List, Dict +from copy import deepcopy +from random import randint + + +class AsyncCursor: + """An async-iterable wrapper around a list, mimicking motor's AsyncIOMotorCursor.""" + + def __init__(self, items: List[dict]): + self._items = items + self._index = 0 + + def __aiter__(self): + self._index = 0 + return self + + async def __anext__(self): + if self._index >= len(self._items): + raise StopAsyncIteration + item = self._items[self._index] + self._index += 1 + return item + + async def to_list(self, length=None): + if length is not None: + return self._items[:length] + return list(self._items) + + def __await__(self): + async def _resolve(): + return self._items + return _resolve().__await__() class TestingDatabase: @@ -6,6 +37,11 @@ class TestingDatabase: db: Dict[str, List[dict]] = {} + @classmethod + def reset_all(cls) -> None: + """Clear all in-memory collections (call between test groups).""" + cls.db.clear() + def __init__(self, collection: str): self._collection = collection @@ -15,64 +51,41 @@ def collection(self) -> str: self.db[self._collection] = [] return self._collection - # def _random_id(self) -> int: - # """Creates a random 8 digit number""" - # res = int(str(randint(0, 99999999)).zfill(8)) - # if res in [x["_id"] for x in self.db[self.collection]]: - # return self._random_id() - # else: - # return res - - def _normalize_dict(self, dictionary: dict) -> dict: - """Changes the {one.two: } to {one: {two: }}""" - for _, d in dictionary.items(): - if isinstance(d, dict): - for key, val in d.items(): - if "." in key: - k1 = key.split(".")[0] - k2 = key.split(".")[1] - d[k1][k2] = val - del d[key] - return dictionary - - async def find_one(self, where: dict) -> Optional[dict]: + @staticmethod + def _resolve_path(obj: dict, dotted_key: str): + """Traverses *obj* along a dotted key and returns (parent, final_key).""" + parts = dotted_key.split(".") + for part in parts[:-1]: + obj = obj[part] + return obj, parts[-1] + + def _matches(self, doc: dict, where: dict) -> bool: + """Check if a document matches all conditions in *where*.""" + for wk, wv in where.items(): + if wk not in doc: + return False + if isinstance(wv, dict) and any(k.startswith("$") for k in wv): + if "$in" in wv and doc[wk] not in wv["$in"]: + return False + continue + if doc[wk] != wv: + return False + return True + + async def find_one(self, where: dict, **kwargs) -> Optional[dict]: coll = self.db[self.collection] for d in coll: - for key, value in d.items(): - if len([k for k, v in where.items() if k == key and v == value]) == len( - where - ): # When all conditions defined in "where" are met - return d + if self._matches(d, where): + return deepcopy(d) - async def find(self, where: dict) -> Optional[list]: + def find(self, where: dict, *args, **kwargs) -> AsyncCursor: coll = self.db[self.collection] - results = [] - - for d in coll: - for key, value in d.items(): - if [ - x - for x in list(where.values()) - if isinstance(x, dict) and "$in" in x.keys() - ]: - for k, v in [ - (k, v) - for k, v in list(where.items()) - if isinstance(v, dict) and "$in" in v.keys() - ]: - if k == key and value in v["$in"]: - results.append(d) - - elif len( - [k for k, v in where.items() if k == key and v == value] - ) == len( - where - ): # When all conditions defined in "where" are met - results.append(d) - - return results + results = [deepcopy(d) for d in coll if self._matches(d, where)] + return AsyncCursor(results) async def insert_one(self, object: dict) -> None: + if "_id" not in object: + object["_id"] = randint(0, 2**63) self.db[self.collection].append(object) async def insert_many(self, objects: List[dict]) -> None: @@ -80,64 +93,52 @@ async def insert_many(self, objects: List[dict]) -> None: await self.insert_one(obj) async def update_one(self, where: dict, update: Dict[str, dict]) -> dict: - # updated = False - operator = list(update.keys())[0] # This does not support multiple keys - - for v in update.values(): # Making sure it is all in the right format - v = self._normalize_dict(v) # lgtm [py/multiple-definition] + operator = list(update.keys())[0] for p, item in enumerate(self.db[self.collection]): - for key, value in item.items(): - if len([k for k, v in where.items() if key == k and value == v]) == len( - where - ): + if self._matches(item, where): + record = self.db[self.collection][p] + for k, val in update[operator].items(): + parent, final = self._resolve_path(record, k) + if operator == "$set": - for k, val in update[operator].items(): - if isinstance(val, dict): - self.db[self.collection][p][k][list(val.keys())[0]] = ( - list(val.values())[0] - ) - else: - self.db[self.collection][p][k] = val - if operator == "$push": - for k, val in update[operator].items(): - if isinstance(val, dict): - self.db[self.collection][p][k][ - list(val.keys())[0] - ].append(list(val.values())[0]) - else: - self.db[self.collection][p][k].append(val) - if operator == "$pull": - for k, val in update[operator].items(): - if isinstance(val, dict): - self.db[self.collection][p][k][ - list(val.keys())[0] - ].remove(list(val.values())[0]) - else: - self.db[self.collection][p][k].remove(val) + parent[final] = val + elif operator == "$push": + parent[final].append(val) + elif operator == "$pull": + parent[final].remove(val) elif operator == "$inc": - for k, val in update[operator].items(): - if isinstance(val, dict): - self.db[self.collection][p][k][ - list(val.keys())[0] - ] += list(val.values())[0] - else: - self.db[self.collection][p][k] += val - # updated = True + parent[final] += val + break - # if not updated: - # self.insert_one(update) - - return update # I only need this when the update would equal the object + return update async def count_documents(self, where: dict = {}) -> int: - return len(await self.find(where) or []) + return len([x async for x in self.find(where)]) async def delete_one(self, where: dict) -> None: - ... # TODO: Implement this + coll = self.db[self.collection] + for i, d in enumerate(coll): + if self._matches(d, where): + coll.pop(i) + return async def delete_many(self, where: dict) -> None: - ... # TODO: Implement this + coll = self.db[self.collection] + self.db[self.collection] = [d for d in coll if not self._matches(d, where)] async def update_many(self, where: dict, update: dict) -> None: - ... # TODO: Implement this + operator = list(update.keys())[0] + for p, item in enumerate(self.db[self.collection]): + if self._matches(item, where): + record = self.db[self.collection][p] + for k, val in update[operator].items(): + parent, final = self._resolve_path(record, k) + if operator == "$set": + parent[final] = val + elif operator == "$push": + parent[final].append(val) + elif operator == "$pull": + parent[final].remove(val) + elif operator == "$inc": + parent[final] += val diff --git a/killua/utils/topgg.py b/killua/utils/topgg.py new file mode 100644 index 000000000..ccdaea311 --- /dev/null +++ b/killua/utils/topgg.py @@ -0,0 +1,109 @@ +"""Top.gg v1 project API helpers (metrics + announcements).""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +logger = logging.getLogger(__name__) + +TOPGG_METRICS_URL = "https://top.gg/api/v1/projects/@me/metrics" +TOPGG_ANNOUNCEMENTS_URL = "https://top.gg/api/v1/projects/@me/announcements" + + +def _normalize_token(raw: str) -> str: + token = raw.strip() + if len(token) >= 2 and token[0] == token[-1] and token[0] in "\"'": + token = token[1:-1].strip() + if token.lower().startswith("bearer "): + token = token[7:].strip() + return token + + +def _token() -> Optional[str]: + value = os.getenv("TOPGG_TOKEN") + if not value: + return None + token = _normalize_token(value) + return token or None + + +def _auth_headers(token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def _log_auth_failure(method: str, url: str, status: int, body: str) -> None: + token = _token() + token_hint = f"length={len(token)}" if token else "unset" + logger.error( + "Top.gg %s %s failed (%s): %s (%s)", + method, + url, + status, + body or "(empty body)", + token_hint, + ) + + +async def _request(session, method: str, url: str, *, json: Optional[dict] = None) -> bool: + token = _token() + if not token: + logger.warning("TOPGG_TOKEN is not set; skipping Top.gg %s %s", method, url) + return False + + try: + async with session.request( + method, url, headers=_auth_headers(token), json=json + ) as resp: + if resp.status >= 400: + body = await resp.text() + _log_auth_failure(method, url, resp.status, body) + return False + return True + except Exception as exc: + logger.error("Top.gg %s %s error: %s", method, url, exc) + return False + + +async def post_metrics( + session, + *, + server_count: int, + shard_count: Optional[int] = None, +) -> bool: + """Push guild/shard counts to Top.gg (v1 projects metrics).""" + payload: dict[str, int] = {"server_count": server_count} + if shard_count is not None: + payload["shard_count"] = shard_count + logger.info( + "Posting Top.gg metrics: server_count=%s shard_count=%s", + server_count, + shard_count, + ) + return await _request(session, "PATCH", TOPGG_METRICS_URL, json=payload) + + +async def post_announcement(session, *, title: str, content: str) -> bool: + """Create a Top.gg project announcement.""" + if len(title) < 3 or len(title) > 100: + logger.warning( + "Top.gg announcement title length %d is outside 3–100; skipping", + len(title), + ) + return False + if len(content) < 10 or len(content) > 2000: + logger.warning( + "Top.gg announcement content length %d is outside 10–2000; skipping", + len(content), + ) + return False + return await _request( + session, + "POST", + TOPGG_ANNOUNCEMENTS_URL, + json={"title": title, "content": content}, + ) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000..c1705c45f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +# Dev / CI tooling (install after requirements.txt: pip install -r requirements-dev.txt) +coverage>=7.6.0,<8 diff --git a/requirements.txt b/requirements.txt index dbe8e52e9..d5cfadcf4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,8 @@ jishaku==2.6.0 kiwisolver==1.4.7 matplotlib==3.9.3 multidict==6.1.0 -numpy==2.0.2 +numpy==2.0.2; python_version < "3.13" +numpy>=2.1.0; python_version >= "3.13" packaging==25.0 pillow==11.0.0 prometheus_client==0.22.1 diff --git a/scripts/check_test_commands.py b/scripts/check_test_commands.py new file mode 100644 index 000000000..05789cd30 --- /dev/null +++ b/scripts/check_test_commands.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Compare discord command names on each cog with integration test class names. + +By default prints gaps and exits 0 (inventory report for maintainers). +Use --strict to exit 1 when any command lacks a test class or any class is orphaned. + +Usage: + python scripts/check_test_commands.py + python scripts/check_test_commands.py --strict +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import killua.args as args_mod + +args_mod.init() +args_mod.Args.test = [] # in-memory DB for imports + +from discord.ext.commands import Command + +from killua.tests.groups import tests +from killua.tests.testing import Testing, _test_class_command_name +from killua.tests.types import Bot + + +def _command_names_for_group(group_cls: type) -> set[str]: + runner = group_cls() + names: set[str] = set() + for cmd in runner.cog.walk_commands(): + if isinstance(cmd, Command): + names.add(cmd.name.lower()) + return names + + +def _test_class_names(group_cls: type) -> set[str]: + runner = group_cls() + out: set[str] = set() + for cls in runner.all_tests: + out.add(_test_class_command_name(cls).lower()) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--strict", + action="store_true", + help="Exit 1 if any command or test class mismatch is found", + ) + args = parser.parse_args() + + missing_any = False + for group_cls in tests: + gname = group_cls.__name__.replace("Testing", "") + if not getattr(group_cls, "requires_command", False): + continue + try: + cmds = _command_names_for_group(group_cls) + classes = _test_class_names(group_cls) + except Exception as exc: + print(f"{gname}: failed to inspect ({exc})", file=sys.stderr) + missing_any = True + continue + missing = sorted(cmds - classes) + extra = sorted(classes - cmds) + if missing or extra: + missing_any = True + print(f"\n=== {gname} ===") + if missing: + print(" commands without test class:", ", ".join(missing)) + if extra: + print(" test classes without command:", ", ".join(extra)) + if missing_any: + if args.strict: + return 1 + print( + "\n(report only; re-run with --strict to fail CI on the gaps above)", + file=sys.stderr, + ) + return 0 + print("All command-bound test groups have matching test classes.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 140b9ea62b633c074f1c7fe58cb0593abe03b380 Mon Sep 17 00:00:00 2001 From: Kile Date: Fri, 22 May 2026 19:59:19 +0200 Subject: [PATCH 2/9] Download test cards in CI --- .github/workflows/python-tests.yml | 3 +++ killua/tests/__init__.py | 3 +++ killua/tests/fixtures.py | 26 ++++++++++++++++++++++++++ killua/tests/groups/cards.py | 9 ++------- killua/tests/groups/shop.py | 16 +++------------- 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 9e6f63731..d808eecf3 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -23,6 +23,9 @@ jobs: - name: Command vs test class inventory (report) run: python scripts/check_test_commands.py + - name: Download cards for tests + run: python -m killua --download public + - name: Tests and coverage gate run: | coverage run -m killua -t diff --git a/killua/tests/__init__.py b/killua/tests/__init__.py index 1ba1a17b1..bab4c1984 100644 --- a/killua/tests/__init__.py +++ b/killua/tests/__init__.py @@ -91,6 +91,9 @@ async def _test_prefix(*_): Bot.command_prefix = _test_prefix await Bot.setup_hook() + from .fixtures import ensure_test_cards + + ensure_test_cards() if json_output: config.SUPPRESS_TEST_TRACEBACKS = True root = logging.getLogger() diff --git a/killua/tests/fixtures.py b/killua/tests/fixtures.py index d0ae2c95b..c69095f56 100644 --- a/killua/tests/fixtures.py +++ b/killua/tests/fixtures.py @@ -2,9 +2,35 @@ from __future__ import annotations +import json +from pathlib import Path + from killua.static.constants import DB from killua.utils.test_db import TestingDatabase +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CARDS_FILE = _REPO_ROOT / "cards.json" + + +def ensure_test_cards() -> None: + """Load Card.raw from cards.json (run ``python -m killua --download public`` first).""" + from killua.utils.classes.card import Card + + if Card.raw: + return + + if not _CARDS_FILE.exists(): + raise FileNotFoundError( + f"{_CARDS_FILE} not found. Download cards with: " + "python -m killua --download public" + ) + + with _CARDS_FILE.open() as handle: + Card.raw = json.load(handle) + + Card.cached_raw = [] + Card.cache.clear() + def reset_test_fixtures() -> None: """Reset in-memory DB, user cache, and bot flags between test command classes.""" diff --git a/killua/tests/groups/cards.py b/killua/tests/groups/cards.py index 5795a0c34..a0f74894c 100644 --- a/killua/tests/groups/cards.py +++ b/killua/tests/groups/cards.py @@ -7,13 +7,12 @@ from ...utils.paginator import Buttons from ...static.constants import PRICES, DEF_SPELLS, VIEW_DEF_SPELLS -import json -from pathlib import Path from random import randint from math import ceil from datetime import datetime, timedelta from unittest.mock import patch +from ..fixtures import ensure_test_cards from ..harnesses import embed_footer_page, press_paginator_button @@ -23,11 +22,7 @@ class TestingCards(Testing): _cards_initialized = False def __init__(self): - if not Card.raw: - cards_file = Path(__file__).parents[3] / "cards.json" - if cards_file.exists(): - with open(cards_file) as f: - Card.raw = json.load(f) + ensure_test_cards() super().__init__(cog=Cards) self._mock_cog_externals() diff --git a/killua/tests/groups/shop.py b/killua/tests/groups/shop.py index 0428b19b4..ca9b03b6b 100644 --- a/killua/tests/groups/shop.py +++ b/killua/tests/groups/shop.py @@ -8,29 +8,19 @@ from ...static.cards import Card import copy +from datetime import datetime from unittest.mock import patch, AsyncMock from ..harnesses import embed_footer_page, press_paginator_button -from pathlib import Path -from datetime import datetime -import json - - -def _ensure_card_catalog() -> None: - if Card.raw: - return - cards_file = Path(__file__).parents[3] / "cards.json" - if cards_file.exists(): - with open(cards_file) as f: - Card.raw = json.load(f) +from ..fixtures import ensure_test_cards class TestingShop(Testing): requires_command = True def __init__(self): - _ensure_card_catalog() + ensure_test_cards() super().__init__(cog=Shop) self.base_context.command = self.command From 1285b29f390fbd7ccae27a9d36304403c0114a83 Mon Sep 17 00:00:00 2001 From: Kile Date: Fri, 22 May 2026 20:01:45 +0200 Subject: [PATCH 3/9] Fix last failing test --- killua/tests/groups/cards_use_spells.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/killua/tests/groups/cards_use_spells.py b/killua/tests/groups/cards_use_spells.py index cc466617c..156f84d75 100644 --- a/killua/tests/groups/cards_use_spells.py +++ b/killua/tests/groups/cards_use_spells.py @@ -545,7 +545,9 @@ async def attack_succeeds_when_not_met(self) -> None: class UseDefense1019(TestingUseSpell): @test async def blocks_sr_attack(self) -> None: - _, tid = await run_attack_against_defender(self, defense_id=1019) + _, tid = await run_attack_against_defender( + self, defense_id=1019, patch_attacker_range="SR" + ) await assert_steal_blocked_by_defense( self.base_context, self.base_author.id, tid, STEAL_TARGET_CARD ) From 94d54518bcb4d21ee6bdf7a970b0f49bf7c8792d Mon Sep 17 00:00:00 2001 From: Kile Date: Fri, 22 May 2026 20:15:31 +0200 Subject: [PATCH 4/9] Make SonarQube analysis use new tests --- .coveragerc | 3 +++ .github/workflows/python-tests.yml | 1 + .github/workflows/sonarqube-analysis.yml | 21 ++++++++++++++++++++- sonar-project.properties | 13 +++++++------ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.coveragerc b/.coveragerc index 86820a7c0..e8e8590e8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -17,3 +17,6 @@ exclude_lines = [html] directory = htmlcov + +[xml] +output = coverage.xml diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index d808eecf3..417858041 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -30,3 +30,4 @@ jobs: run: | coverage run -m killua -t coverage report + coverage xml diff --git a/.github/workflows/sonarqube-analysis.yml b/.github/workflows/sonarqube-analysis.yml index ef1d3ab64..5fd5f4a43 100644 --- a/.github/workflows/sonarqube-analysis.yml +++ b/.github/workflows/sonarqube-analysis.yml @@ -9,11 +9,30 @@ jobs: sonarqube: name: SonarQube runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Download cards for tests + run: python -m killua --download public + + - name: Run tests and export coverage for Sonar + run: | + coverage run -m killua -t + coverage xml + - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v6 env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/sonar-project.properties b/sonar-project.properties index f0a0876af..010d14736 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,14 +1,15 @@ sonar.projectKey=Kile_Killua sonar.organization=kile - -# This is the name and version displayed in the SonarCloud UI. sonar.projectName=Killua sonar.projectVersion=1.4.0 +# Python bot sources only (not the whole monorepo). +sonar.sources=killua +sonar.tests=killua/tests +sonar.python.version=3.13 -# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=. +# Cobertura XML from: coverage run … && coverage xml +sonar.python.coverage.reportPaths=coverage.xml -# Encoding of the source code. Default is default system encoding -sonar.sourceEncoding=UTF-8 \ No newline at end of file +sonar.sourceEncoding=UTF-8 From efeec1e9304167e4acbfe32a3873e8ea937c0f97 Mon Sep 17 00:00:00 2001 From: Kile Date: Sat, 23 May 2026 00:00:16 +0200 Subject: [PATCH 5/9] Cleaning up --- killua/args.py | 11 ++-- killua/bot.py | 42 ++++++------- killua/cogs/actions.py | 54 ++++++++-------- killua/cogs/api.py | 50 +++++++-------- killua/cogs/cards.py | 58 +++++++---------- killua/cogs/dev.py | 24 +++---- killua/cogs/economy.py | 16 ++--- killua/cogs/events.py | 26 ++++---- killua/cogs/games.py | 42 ++++++------- killua/cogs/help.py | 8 +-- killua/cogs/image_manipulation.py | 10 +-- killua/cogs/moderation.py | 5 +- killua/cogs/premium.py | 34 +++++----- killua/cogs/prometheus.py | 8 +-- killua/cogs/shop.py | 24 +++---- killua/cogs/small_commands.py | 4 +- killua/cogs/tags.py | 34 +++++----- killua/cogs/todo.py | 42 ++++++------- killua/cogs/web_scraping.py | 11 ++-- killua/static/cards.py | 4 +- killua/static/constants.py | 29 +++------ killua/tests/README.md | 16 ++--- killua/tests/__init__.py | 14 +++-- killua/tests/groups/actions.py | 15 +++-- killua/tests/groups/api.py | 37 ++++------- killua/tests/groups/bot_cov.py | 2 +- killua/tests/groups/cards.py | 17 ++--- killua/tests/groups/cards_use_spells.py | 5 +- killua/tests/groups/deep_coverage.py | 5 +- killua/tests/groups/dev.py | 11 ++-- killua/tests/groups/economy.py | 12 ++-- killua/tests/groups/games.py | 17 ++--- killua/tests/groups/help.py | 4 +- killua/tests/groups/image_manipulation.py | 13 ++-- killua/tests/groups/moderation.py | 5 +- killua/tests/groups/premium.py | 13 ++-- killua/tests/groups/prometheus_cov.py | 2 +- killua/tests/groups/shop.py | 25 ++++---- killua/tests/groups/small_commands.py | 25 ++++---- killua/tests/groups/tags.py | 13 ++-- killua/tests/groups/todo.py | 17 ++--- killua/tests/groups/unit_boost.py | 25 ++++---- killua/tests/groups/web_scraping.py | 9 +-- killua/tests/harnesses/assertions.py | 8 +-- killua/tests/harnesses/context.py | 2 +- killua/tests/harnesses/dm_view.py | 2 +- killua/tests/harnesses/interaction.py | 5 +- killua/tests/harnesses/member_dm.py | 4 +- killua/tests/harnesses/paginator.py | 8 +-- killua/tests/harnesses/poll_wyr.py | 8 +-- killua/tests/harnesses/spell_use.py | 35 +++++------ killua/tests/harnesses/views.py | 10 +-- killua/tests/testing.py | 40 +++++++++--- killua/tests/types/bot.py | 11 ++-- killua/tests/types/channel.py | 21 ++++--- killua/tests/types/context.py | 13 ++-- killua/tests/types/guild.py | 26 ++++---- killua/tests/types/interaction.py | 2 +- killua/tests/types/member.py | 7 +-- killua/tests/types/permissions.py | 6 +- killua/tests/types/role.py | 6 +- killua/tests/types/testing_results.py | 8 +-- killua/utils/checks.py | 4 +- killua/utils/classes/book.py | 23 +++---- killua/utils/classes/card.py | 36 +++++------ killua/utils/classes/guild.py | 24 +++---- killua/utils/classes/lootbox.py | 26 ++++---- killua/utils/classes/todo.py | 38 ++++++------ killua/utils/classes/user.py | 76 +++++++++++------------ killua/utils/gif.py | 11 ++-- killua/utils/interactions.py | 6 +- killua/utils/paginator.py | 16 ++--- killua/utils/test_db.py | 36 ++++++----- killua/utils/topgg.py | 35 ++++++++--- scripts/check_test_commands.py | 3 +- 75 files changed, 700 insertions(+), 694 deletions(-) diff --git a/killua/args.py b/killua/args.py index fc568f850..5677c0d88 100644 --- a/killua/args.py +++ b/killua/args.py @@ -1,14 +1,13 @@ import argparse -from typing import Optional class _Args: - development: Optional[bool] = None - migrate: Optional[bool] = None - test: Optional[bool] = None + development: bool | None = None + migrate: bool | None = None + test: bool | None = None test_json: bool = False - log: Optional[str] = None - download: Optional[str] = None + log: str | None = None + download: str | None = None @classmethod def get_args(cls) -> None: diff --git a/killua/bot.py b/killua/bot.py index aa986ff87..93112319a 100644 --- a/killua/bot.py +++ b/killua/bot.py @@ -15,7 +15,7 @@ from inspect import signature, Parameter from functools import partial from yaml import full_load -from typing import Coroutine, Union, Dict, List, Optional, Tuple, cast +from typing import Coroutine, cast from .static.enums import Category from .utils.interactions import Modal @@ -75,9 +75,9 @@ def __init__(self, *args, **kwargs): self.run_in_docker = False self.force_local = False self.startup_datetime = datetime.now() - self.__cached_formatted_commands: List[commands.Command] = [] - self.cached_skus: List[discord.SKU] = [] - self.cached_entitlements: List[discord.Entitlement] = [] + self.__cached_formatted_commands: list[commands.Command] = [] + self.cached_skus: list[discord.SKU] = [] + self.cached_entitlements: list[discord.Entitlement] = [] # Load ../api/Rocket.toml to get port under [debug] with open("api/Rocket.toml") as f: @@ -211,9 +211,9 @@ async def close(self): def __format_command( self, - res: Dict[str, Dict[str, Union[str, Dict[str, str], List[commands.Command]]]], + res: dict[str, dict[str, str | dict[str, str] | list[commands.Command]]], cmd: discord.app_commands.Command, - ) -> Dict[str, Dict[str, Union[str, Dict[str, str], List[commands.Command]]]]: + ) -> dict[str, dict[str, str | dict[str, str] | list[commands.Command]]]: """Adds a command to a dict of formatted commands""" if ( @@ -232,7 +232,7 @@ def __format_command( return res - def _get_group(self, command: commands.HybridCommand) -> Optional[str]: + def _get_group(self, command: commands.HybridCommand) -> str | None: if isinstance(command.cog, commands.GroupCog): return command.cog.__cog_group_name__ else: @@ -240,7 +240,7 @@ def _get_group(self, command: commands.HybridCommand) -> Optional[str]: def get_formatted_commands( self, - ) -> Dict[str, Dict[str, Union[str, Dict[str, str], List[commands.Command]]]]: + ) -> dict[str, dict[str, str | dict[str, str] | list[commands.Command]]]: """Gets a dictionary of formatted commands""" if self.__cached_formatted_commands: return self.__cached_formatted_commands @@ -264,15 +264,15 @@ def get_formatted_commands( self.cached_commands = res return res - def get_raw_formatted_commands(self) -> List[commands.Command]: + def get_raw_formatted_commands(self) -> list[commands.Command]: # If the group doesn't exist, check if the command exists all_commands = [v["commands"] for v in self.get_formatted_commands().values()] # combine all individual lists in all_commands into one in one line return [item for sublist in all_commands for item in sublist] async def _get_bytes( - self, image: Union[discord.Attachment, str] - ) -> Union[None, BytesIO]: + self, image: discord.Attachment | str + ) -> None | BytesIO: if isinstance(image, discord.Attachment): return BytesIO(await image.read()) else: @@ -311,7 +311,7 @@ async def find_dominant_color(self, url: str) -> int: async def find_user( self, ctx: commands.Context, user: str - ) -> Union[discord.Member, discord.User, None]: + ) -> discord.Member | discord.User | None: """Attempts to create a member or user object from the passed string""" try: res = await commands.MemberConverter().convert(ctx, user) @@ -327,7 +327,7 @@ async def find_user( return return res - def get_lootbox_from_name(self, name: str) -> Union[int, None]: + def get_lootbox_from_name(self, name: str) -> int | None: """Gets a lootbox id from its name""" for k, v in LOOTBOXES.items(): if name.lower() == v["name"].lower(): @@ -335,7 +335,7 @@ def get_lootbox_from_name(self, name: str) -> Union[int, None]: def callback_from_command( self, command: Coroutine, message: bool, *args, **kwargs - ) -> Coroutine[discord.Interaction, Union[discord.Member, discord.Message], None]: + ) -> Coroutine[discord.Interaction, discord.Member | discord.Message, None]: """Turn a command function into a context menu callback""" if message: @@ -367,7 +367,7 @@ async def _get_text_response_modal( interaction: discord.Interaction = None, *args, **kwargs, - ) -> Union[str, None]: + ) -> str | None: """Gets a response from a textinput UI""" modal = Modal(title="Answer the question and click submit", timeout=timeout) textinput = discord.ui.TextInput(label=text, *args, **kwargs) @@ -395,7 +395,7 @@ async def _get_text_response_message( timeout_message: str = None, *args, **kwargs, - ) -> Union[str, None]: + ) -> str | None: """Gets a response by waiting a message sent by the user""" def check(m: discord.Message): @@ -429,7 +429,7 @@ async def get_text_response( interaction: discord.Interaction = None, *args, **kwargs, - ) -> Union[str, None]: + ) -> str | None: """Gets a response from either a textinput UI or by waiting for a response""" if (ctx.interaction and not ctx.interaction.response.is_done()) or interaction: @@ -441,7 +441,7 @@ async def get_text_response( ctx, text, timeout, timeout_message, *args, **kwargs ) - def sha256_for_api(self, endpoint: str, expires_in_seconds: int) -> Tuple[str, str]: + def sha256_for_api(self, endpoint: str, expires_in_seconds: int) -> tuple[str, str]: """Generates a sha256 hash for the Killua API""" expiry = str( int((datetime.now() + timedelta(seconds=expires_in_seconds)).timestamp()) @@ -479,7 +479,7 @@ async def make_embed_from_api( no_token: bool = False, thumbnail: bool = False, force_fetch: bool = False, - ) -> Tuple[discord.Embed, Optional[discord.File]]: + ) -> tuple[discord.Embed, discord.File | None]: """ Makes an embed from a Killua API image url. @@ -600,7 +600,7 @@ async def _send_messageable_response( async def send_message( self, - messageable: Union[discord.abc.Messageable, discord.Interaction], + messageable: discord.abc.Messageable | discord.Interaction, *args, **kwargs, ) -> discord.Message: @@ -640,7 +640,7 @@ def is_user_installed(self, ctx: commands.Context) -> bool: return False return not ctx.interaction.is_guild_integration() - def get_command_from_id(self, id: int) -> Union[discord.app_commands.Command, None]: + def get_command_from_id(self, id: int) -> discord.app_commands.Command | None: for cmd in [*self.walk_commands(), *self.tree.walk_commands()]: if cmd.extras["id"] == id: return cmd diff --git a/killua/cogs/actions.py b/killua/cogs/actions.py index 930e513b7..f3f3b5bdf 100644 --- a/killua/cogs/actions.py +++ b/killua/cogs/actions.py @@ -3,7 +3,7 @@ import asyncio, os, random from pathlib import Path from logging import info, warning -from typing import List, Union, Optional, cast, Tuple, Dict, TypedDict +from typing import cast, TypedDict from killua.bot import BaseBot from killua.utils.checks import check @@ -43,7 +43,7 @@ def from_api(cls, data: dict) -> "Artist": class ArtistAsset(TypedDict): url: str - artist: Optional[Artist] + artist: Artist | None featured: bool @classmethod @@ -121,7 +121,7 @@ async def cog_load(self): f"{PrintColors.OKGREEN}{number_of_hug_imgs} hugs loaded.{PrintColors.ENDC}" ) - async def request_action(self, endpoint: str) -> Union[AnimeAsset, ArtistAsset]: + async def request_action(self, endpoint: str) -> AnimeAsset | ArtistAsset: """ Fetch an image from the API for the action commands @@ -154,7 +154,7 @@ async def request_action(self, endpoint: str) -> Union[AnimeAsset, ArtistAsset]: raise APIException(json["message"] if "message" in json else await r.text()) def add_credit( - self, embed: discord.Embed, asset: Union[ArtistAsset, AnimeAsset] + self, embed: discord.Embed, asset: ArtistAsset | AnimeAsset ) -> discord.Embed: """ Adds the artist credit to the embed @@ -192,7 +192,7 @@ async def save_stat( endpoint: str, targeted: bool = False, amount: int = 1, - ) -> Optional[str]: + ) -> str | None: """ Saves the action being done on a user and returns the badge if the user has reached a milestone for the action. @@ -201,7 +201,7 @@ async def save_stat( badge = await db_user.add_action(endpoint, targeted, amount) return badge - def generate_users(self, users: List[discord.User], title: str) -> str: + def generate_users(self, users: list[discord.User], title: str) -> str: """ Parses the list of members and returns a string with their names, making sure the string is not too long for the embed title @@ -229,7 +229,7 @@ def generate_users(self, users: List[discord.User], title: str) -> str: userlist = userlist + f", {user.display_name}" return userlist - async def _get_image_url(self, endpoint: str) -> Union[AnimeAsset, ArtistAsset]: + async def _get_image_url(self, endpoint: str) -> AnimeAsset | ArtistAsset: """ Gets an image URL and extra info from the API for the action commands or, for the hug command, returns a random image from the list of images @@ -256,11 +256,11 @@ async def _get_image_url(self, endpoint: str) -> Union[AnimeAsset, ArtistAsset]: async def action_embed( self, endpoint: str, - author: Union[str, discord.User], - users: Union[str, List[discord.User]], + author: str | discord.User, + users: str | list[discord.User], disabled: int = 0, user_installed: bool = False, - ) -> Tuple[discord.Embed, Optional[discord.File]]: + ) -> tuple[discord.Embed, discord.File | None]: """ Creates an embed for the action commands with the members and author provided as well as adding the image and action text @@ -311,7 +311,7 @@ async def action_embed( async def no_argument( self, ctx: commands.Context - ) -> Optional[Tuple[discord.Embed, Optional[discord.File]]]: + ) -> tuple[discord.Embed, discord.File | None] | None: """ The user didn't provide any (valid) arguments to the command, so they are asked if they want to be hugged. If they respond with "yes", the command is executed with the author as the target. @@ -361,17 +361,17 @@ async def _save_stat_for( async def get_allowed_users( self, - users: List[discord.User], + users: list[discord.User], command_name: str, - ) -> Tuple[List[discord.User], int, List[discord.User]]: + ) -> tuple[list[discord.User], int, list[discord.User]]: """ Returns a list of users that are allowed to use the action command. Also provides the number of people that have user installed the bot. This is to avoid looping through the list of users twice. """ - allowed: List[discord.User] = [] + allowed: list[discord.User] = [] disabled = 0 - has_user_installed: List[discord.User] = [] + has_user_installed: list[discord.User] = [] for user in users: m = await User.new(user.id) if m.action_settings and self.has_disabled(m, command_name): @@ -384,12 +384,12 @@ async def get_allowed_users( async def _get_return_view( self, - messagable: Union[commands.Context, discord.Interaction], + messagable: commands.Context | discord.Interaction, action: str, author: discord.User, - users: List[discord.User], - has_user_installed: List[discord.User], - ) -> Optional[View]: + users: list[discord.User], + has_user_installed: list[discord.User], + ) -> View | None: if users is None: return None # No users to return to @@ -421,10 +421,10 @@ async def _get_return_view( async def _do_action( self, - messageable: Union[commands.Context, discord.Interaction], - users: List[discord.User], + messageable: commands.Context | discord.Interaction, + users: list[discord.User], action: str, - author: Union[discord.User, discord.Member], + author: discord.User | discord.Member, ) -> None: """ Executes an action command with the given members @@ -504,9 +504,9 @@ async def get_image(self, ctx: commands.Context) -> discord.Message: async def do_action( self, - ctx: Union[commands.Context, discord.Interaction], - users: List[discord.User] = None, - action: Optional[str] = None, + ctx: commands.Context | discord.Interaction, + users: list[discord.User] = None, + action: str | None = None, ) -> None: """ Wrapper for _do_action to catch any exceptions raised @@ -801,8 +801,8 @@ def _get_view(self, id: int, current: dict) -> View: def adjust_settings_embed( self, embed: discord.Embed, - current: Dict[str, bool], - view_values: Optional[Dict[str, bool]] = None, + current: dict[str, bool], + view_values: dict[str, bool] | None = None, ) -> None: """ Adjusts the embed to show the current settings for each action. diff --git a/killua/cogs/api.py b/killua/cogs/api.py index a18e4ef1a..b904a3d48 100644 --- a/killua/cogs/api.py +++ b/killua/cogs/api.py @@ -4,13 +4,13 @@ from os import environ from random import choices from json import loads, dumps -from asyncio import create_task, sleep +from asyncio import create_task from datetime import datetime, timedelta from zmq import ROUTER, Poller, POLLIN from zmq.asyncio import Context from io import BytesIO from PIL import Image, ImageDraw, ImageChops -from typing import Tuple, Optional +from typing import cast import logging from logging import error from copy import deepcopy @@ -20,7 +20,11 @@ from killua.static.enums import Booster from killua.utils.classes import User, Guild from killua.cogs.tags import Tag, Tags -from killua.utils.topgg import post_announcement +from killua.utils.topgg import ( + post_announcement, + TOPGG_TITLE_MAX, + TOPGG_CONTENT_MAX, +) from killua.static.constants import ( DB, LOOTBOXES, @@ -39,12 +43,6 @@ UPDATE_AFTER, ) -from typing import List, Dict, Optional, Union, cast - -TOPGG_ANNOUNCEMENT_TITLE_MAX = 100 -TOPGG_ANNOUNCEMENT_CONTENT_MAX = 2000 - - class NewsMessage: def __init__( self, @@ -54,10 +52,10 @@ def __init__( content: str, author: str, _type: str, - version: Optional[str], - images: List[str], - links: Optional[Dict[str, str]], - notify_users: Optional[List[int]], + version: str | None, + images: list[str], + links: dict[str, str] | None, + notify_users: list[int] | None, timestamp: datetime, ): self.client = client @@ -123,7 +121,7 @@ def relevant_ping(self) -> int: raise ValueError("Invalid news type") @property - def guild(self) -> Optional[discord.Guild]: + def guild(self) -> discord.Guild | None: return self.client.get_guild(GUILD) or None @property @@ -133,7 +131,7 @@ def url(self) -> str: async def _make_view( self, include_ping=True - ) -> Tuple[discord.ui.LayoutView, Optional[List[discord.File]]]: + ) -> tuple[discord.ui.LayoutView, list[discord.File] | None]: """Creates a discord.ui.Container for the news message""" last_version = None if self._type == "update": @@ -344,7 +342,7 @@ def crop_to_circle(self, im: Image.Image) -> Image.Image: return im.copy() async def streak_image( - self, data: List[Union[discord.User, str]], reward: str = None + self, data: list[discord.User | str], reward: str = None ) -> BytesIO: """Creates an image of the streak path and returns it as a BytesIO""" if len(data) != 11: @@ -429,7 +427,7 @@ def _get_reward(self, streak: int, weekend: bool = False) -> int: def _create_path( self, streak: int, user: discord.User, url: str - ) -> List[Union[discord.User, str]]: + ) -> list[discord.User | str]: """ Creates a path illustrating where the user currently is with vote rewards and what the next rewards are as well as already claimed ones like --:boxemoji:--⚫️--:boxemoji:-- @@ -492,7 +490,7 @@ async def handle_vote(self, data: dict) -> None: await user.add_vote(platform) VOTES.labels(platform).inc() streak = user.voting_streak[platform]["streak"] - reward: Union[int, Booster] = self._get_reward( + reward: int | Booster = self._get_reward( streak, "isWeekend" in data and data["isWeekend"] is not None, # Could exist but be None so it needs an or False @@ -569,7 +567,7 @@ async def handle_vote(self, data: dict) -> None: except discord.HTTPException: pass - async def top(self, _) -> List[dict]: + async def top(self, _) -> list[dict]: """Returns a list of the top 50 users by the amount of jenny they have""" members = await DB.teams.find( {"id": {"$in": [x.id for x in self.client.users]}} @@ -635,7 +633,7 @@ async def commands(self, _) -> dict: """Returns all commands with descriptions etc""" raw = self.client.get_raw_formatted_commands() - to_be_returned: Dict[str, Dict[str, Union[str, list]]] = {} + to_be_returned: dict[str, dict[str, str | list]] = {} for cmd in raw: formatted = self.format_command(cmd) @@ -981,11 +979,11 @@ async def news_edit(self, data: dict) -> dict: return {"news_id": news_id, "message_id": news_item.get("messageId")} - async def _send_discord_message(self, data: dict) -> int: + async def _send_discord_message(self, data: dict) -> int | None: """Send a Discord message for a news item""" news_message = NewsMessage.from_data(self.client, data) - if news_message.timestamp < UPDATE_AFTER: # or self.client.is_dev: - # Don't send messages for old news items or in dev mode + if news_message.timestamp < UPDATE_AFTER: + # Don't send messages for old news items return None message_id = await news_message.send() return message_id @@ -1014,7 +1012,7 @@ async def _publish_topgg_announcement(self, data: dict) -> None: @staticmethod def _format_topgg_title( - news_type: str, title: str, *, max_len: int = TOPGG_ANNOUNCEMENT_TITLE_MAX + news_type: str, title: str, *, max_len: int = TOPGG_TITLE_MAX ) -> str: full = f"{news_type.capitalize()}: {title}" if len(full) <= max_len: @@ -1026,7 +1024,7 @@ def _format_topgg_content( news_message: NewsMessage, content: str, *, - max_len: int = TOPGG_ANNOUNCEMENT_CONTENT_MAX, + max_len: int = TOPGG_CONTENT_MAX, ) -> str: if len(content) <= max_len: return content @@ -1238,7 +1236,7 @@ async def guild_tag_edit(self, data: dict) -> dict: return {"success": True, "message": "Tag edited successfully"} - async def guild_command_usage(self, data: dict) -> Union[dict, list]: # pragma: no cover + async def guild_command_usage(self, data: dict) -> dict | list: # pragma: no cover """Returns command usage stats for the server""" if self.client.run_in_docker is False: return {"error": "Command usage stats are only available when running in Docker."} diff --git a/killua/cogs/cards.py b/killua/cogs/cards.py index 0c62f9191..71a26780c 100644 --- a/killua/cogs/cards.py +++ b/killua/cogs/cards.py @@ -4,17 +4,7 @@ from discord.ext import commands from datetime import datetime, timedelta -from typing import ( - Union, - List, - Optional, - Tuple, - Optional, - Dict, - Literal, - Any, - cast, -) +from typing import Literal, Any, cast from killua.bot import BaseBot from killua.utils.checks import check @@ -42,7 +32,7 @@ class Cards(commands.GroupCog, group_name="cards"): def __init__(self, client: BaseBot): self.client = client - self.cardname_cache: Dict[int, Tuple[str, str]] = {} + self.cardname_cache: dict[int, tuple[str, str]] = {} self._init_menus() def _init_menus(self) -> None: @@ -87,7 +77,7 @@ async def all_cards_autocomplete( self, interaction: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Autocomplete for all cards""" if not self.cardname_cache: for card in Card.raw: @@ -121,7 +111,7 @@ async def all_cards_autocomplete( ], ][0:25] - def _get_single_reward(self, score: int) -> Tuple[int, int]: + def _get_single_reward(self, score: int) -> tuple[int, int]: if score == 1: if random.randint(1, 10) < 5: return 1, random.choice(self.reward_cache["item"]) @@ -139,9 +129,9 @@ def _get_single_reward(self, score: int) -> Tuple[int, int]: card = random.choice(self.reward_cache["monster"][rarities]) return amount, card - def _construct_rewards(self, score: int) -> List[Tuple[int, Card]]: + def _construct_rewards(self, score: int) -> list[tuple[int, Card]]: # reward_score will be minutes/10080 which equals a week. Max rewards will get returned once a user has hunted for a week - rewards: List[Tuple[int, Card]] = [] + rewards: list[tuple[int, Card]] = [] if score >= 1: rewards.append(self._get_single_reward(1)) @@ -151,7 +141,7 @@ def _construct_rewards(self, score: int) -> List[Tuple[int, Card]]: r = self._get_single_reward(score) rewards.append(r) - final_rewards: List[Tuple[int, Card]] = [] + final_rewards: list[tuple[int, Card]] = [] for reward in rewards: # This avoid duplicates e.g. 4xPaladins Necklace, 2xPaladins Necklace => 6xPaladins Necklace if reward[1] in (l := [y for _, y in final_rewards]): @@ -165,11 +155,11 @@ def _construct_rewards(self, score: int) -> List[Tuple[int, Card]]: return final_rewards async def _format_rewards( - self, rewards: List[Tuple[int, Card]], user: User - ) -> Tuple[List[List[Union[int, Dict[str, bool]]]], List[str], bool]: + self, rewards: list[tuple[int, Card]], user: User + ) -> tuple[list[list[int | dict[str, bool]]], list[str], bool]: """Formats the generated rewards for further use""" - formatted_rewards: List[List[Union[int, Dict[str, bool]]]] = [] - formatted_text: List[str] = [] + formatted_rewards: list[list[int | dict[str, bool]]] = [] + formatted_text: list[str] = [] added_in_jenny = 0 for reward in rewards: @@ -221,7 +211,7 @@ async def book(self, ctx: commands.Context, page: int = 1): f"Please choose a page number between 1 and {6+math.ceil(len(user.fs_cards)/18)}" ) - async def make_embed(page, *_) -> Tuple[discord.Embed, discord.File]: + async def make_embed(page, *_) -> tuple[discord.Embed, discord.File]: return await Book(self.client).create(ctx.author, page) return await Paginator( @@ -234,7 +224,7 @@ async def make_embed(page, *_) -> Tuple[discord.Embed, discord.File]: async def _gen_to_be_sold( self, user: User, sell_opt: SellOptions - ) -> List[Tuple[int, Any]]: + ) -> list[tuple[int, Any]]: """ Decides which cards to be sold based on the sell option """ @@ -262,7 +252,7 @@ async def _gen_to_be_sold( return to_be_sold async def _find_to_be_gained( - self, to_be_sold: List[Tuple[int, Any]], entitled_to_double: bool + self, to_be_sold: list[tuple[int, Any]], entitled_to_double: bool ) -> int: """Finds the money to be gained from selling the cards""" to_be_gained = 0 @@ -427,7 +417,7 @@ async def swap_cards_autocomplete( self, interaction: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Autocomplete for the swap command""" if not self.cardname_cache: @@ -765,7 +755,7 @@ async def _check(self, ctx: commands.Context, card: str): async def _member_converter( self, ctx: commands.Context, data: str - ) -> Optional[discord.Member]: + ) -> discord.Member | None: """Converts the data to a discord.Member if possible""" try: return await commands.MemberConverter().convert(ctx, data) @@ -773,8 +763,8 @@ async def _member_converter( return None async def _use_converter( - self, ctx: commands.Context, args: Union[int, str, None] - ) -> Union[discord.Member, int, str, None]: + self, ctx: commands.Context, args: int | str | None + ) -> discord.Member | int | str | None: """Converts the args to a discord.Member, int or str if possible""" if not args: return None @@ -793,12 +783,12 @@ async def _use_check( self, ctx: commands.Context, card: str, - args: Optional[Union[discord.Member, int, str]], - add_args: Optional[int], + args: discord.Member | int | str | None, + add_args: int | None, ) -> Card: """Makes sure the inputs are valid if they exist""" try: - card: Union[IndividualCard, Card] = Card(card) + card: IndividualCard | Card = Card(card) except CardNotFound: raise CheckFailure("Invalid card id") @@ -830,11 +820,11 @@ async def _use_check( async def _use_core(self, ctx: commands.Context, card: Card, *args) -> None: """This passes the execution to the right class""" - card_class: Union[Card, IndividualCard] = next( + card_class: Card | IndividualCard = next( (c for c in Card.__subclasses__() if c.__name__ == f"Card{card.id}") ) - l: List[Dict[str, Any]] = [] + l: list[dict[str, Any]] = [] for p, (k, v) in enumerate( [ x @@ -873,7 +863,7 @@ async def use_cards_autocomplete( self, interaction: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: if not self.cardname_cache: for card in Card.raw: self.cardname_cache[card["id"]] = card["name"], card["type"] diff --git a/killua/cogs/dev.py b/killua/cogs/dev.py index 069f8ff87..deca6419e 100644 --- a/killua/cogs/dev.py +++ b/killua/cogs/dev.py @@ -2,7 +2,7 @@ import discord import math -from typing import List, Tuple, Union, Literal, cast, Dict +from typing import Literal, cast from io import BytesIO from datetime import datetime from matplotlib import pyplot as plt @@ -49,7 +49,7 @@ async def version_autocomplete( self, _: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: if not self.version_cache: self.version_cache = [ @@ -65,7 +65,7 @@ async def version_autocomplete( def _create_piechart( self, - data: List[list], + data: list[list], ) -> discord.File: """Creates a piechart with the given data""" labels = [x[0] for x in data] @@ -87,7 +87,7 @@ def _create_piechart( return file def _create_graph( - self, dates: List[datetime], y_points: List[int], label: str + self, dates: list[datetime], y_points: list[int], label: str ) -> BytesIO: """Creates a graph with y over time supplied in the dates list""" plt.style.use("seaborn-v0_8") # After testing this is the best theme @@ -110,7 +110,7 @@ def _create_graph( return buf - def _calc_predictions(self, values: List[int]) -> dict: + def _calc_predictions(self, values: list[int]) -> dict: """Calculates various predictions for the given values""" # Calculates the average change between one value compared to the next one in the list @@ -134,8 +134,8 @@ def _calc_predictions(self, values: List[int]) -> dict: } def _get_stats_embed( - self, data: List[dict], embed: discord.Embed, type: str - ) -> Tuple[discord.Embed, discord.File]: + self, data: list[dict], embed: discord.Embed, type: str + ) -> tuple[discord.Embed, discord.File]: """Creates an embed with the stats of the given type""" dates = [x["date"] for x in data if type in x] type_list = [x[type] for x in data if type in x] @@ -174,7 +174,7 @@ def _get_stats_embed( ) return embed, file - async def all_top(self, ctx: commands.Context, top: List[tuple]) -> None: # pragma: no cover + async def all_top(self, ctx: commands.Context, top: list[tuple]) -> None: # pragma: no cover """Shows a list of all top commands""" def make_embed(page, embed: discord.Embed, pages): @@ -202,7 +202,7 @@ def make_embed(page, embed: discord.Embed, pages): ).start() async def group_top( - self, ctx: commands.Context, top: List[tuple], interaction: discord.Interaction + self, ctx: commands.Context, top: list[tuple], interaction: discord.Interaction ) -> None: # pragma: no cover """Displays a pie chart of the top used commands in a group""" # A list of all valid groups as strings @@ -265,7 +265,7 @@ def get_command_extras(self, cmd: str): async def initial_top(self, ctx: commands.Context) -> None: # pragma: no cover # Convert the ids to actually command names - usage_data: Dict[str, int] = (await DB.const.find_one({"_id": "usage"}))[ + usage_data: dict[str, int] = (await DB.const.find_one({"_id": "usage"}))[ "command_usage" ] usage_data_formatted = {} @@ -446,7 +446,7 @@ async def update(self, ctx: commands.Context, version: str = None): ) async def blacklist(self, ctx: commands.Context, user: str, *, reason=None): """Blacklisting bad people like Hisoka. Owner restricted""" - discord_user: Union[discord.User, None] = await self.client.find_user(ctx, user) + discord_user: discord.User | None = await self.client.find_user(ctx, user) if not discord_user: return await ctx.send("Invalid user!", ephermal=True) # Inserting the bad person into my database @@ -474,7 +474,7 @@ async def blacklist(self, ctx: commands.Context, user: str, *, reason=None): @discord.app_commands.describe(user="The user to whitelist") async def whitelist(self, ctx: commands.Context, user: str): """Whitelists a user. Owner restricted""" - user: Union[discord.User, None] = await self.client.find_user(user) + user: discord.User | None = await self.client.find_user(user) if not user: return await ctx.send("Invalid user!", ephermal=True) diff --git a/killua/cogs/economy.py b/killua/cogs/economy.py index e23cb6062..240aa334c 100644 --- a/killua/cogs/economy.py +++ b/killua/cogs/economy.py @@ -1,6 +1,6 @@ import discord from discord.ext import commands -from typing import Union, List, Tuple, Dict, cast +from typing import cast from datetime import datetime from random import randint @@ -57,18 +57,18 @@ def _init_menus(self) -> None: for menu in menus: self.client.tree.add_command(menu) - async def _get_user(self, user_id: int) -> Union[discord.User, None]: + async def _get_user(self, user_id: int) -> discord.User | None: u = self.client.get_user(user_id) if not u: u = await self.client.fetch_user(user_id) return u - def _fieldify_lootboxes(self, lootboxes: List[int]) -> List[dict]: + def _fieldify_lootboxes(self, lootboxes: list[int]) -> list[dict]: """Creates a list of fields from the lootboxes in the passed list""" - lbs: List[Tuple[int, int]] = ( + lbs: list[tuple[int, int]] = ( [] ) # A list containing the a tuple with the amount and id of the lootboxes owned - res: List[dict] = [] # The fields to be returned + res: list[dict] = [] # The fields to be returned for lb in lootboxes: if lb not in (l := [y for _, y in lbs]): @@ -90,7 +90,7 @@ def _fieldify_lootboxes(self, lootboxes: List[int]) -> List[dict]: return res - def _fieldify_boosters(self, boosters: Dict[int, int]): + def _fieldify_boosters(self, boosters: dict[int, int]): """Creates a string from the boosters in the passed dict""" start = ( "You have the following boosters:\n" @@ -102,7 +102,7 @@ def _fieldify_boosters(self, boosters: Dict[int, int]): return start async def _getmember( - self, user: Union[discord.Member, discord.User] + self, user: discord.Member | discord.User ) -> discord.Embed: """A function to handle getting infos about a user for less messy code""" joined = self.client.convert_to_timestamp(user.id) @@ -179,7 +179,7 @@ async def _lb(self, ctx: commands.Context, limit: int = 10) -> dict: async def lootbox_autocomplete( self, _: discord.Interaction, current: str - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """A function to autocomplete the lootbox name""" options = [] for id, lb in LOOTBOXES.items(): diff --git a/killua/cogs/events.py b/killua/cogs/events.py index b2348e9df..e78973d03 100644 --- a/killua/cogs/events.py +++ b/killua/cogs/events.py @@ -9,7 +9,7 @@ from discord.ext import commands, tasks from PIL import Image from enum import Enum -from typing import Dict, List, Tuple, Optional, cast +from typing import cast from matplotlib import pyplot as plt from pymongo.errors import ServerSelectionTimeoutError @@ -407,7 +407,7 @@ async def on_member_join(self, member: discord.Member): # except discord.Forbidden: # pass - def _create_piechart(self, data: List[list], title: str) -> discord.File: + def _create_piechart(self, data: list[list], title: str) -> discord.File: """Creates a piechart with the given data""" data = [l for l in data if l[1] > 0] # We want to avoid a 0% slice @@ -485,7 +485,7 @@ async def _end_poll( for pos, field in enumerate(interaction.message.embeds[0].fields) ] else: - votes: Dict[int, list] = guild.polls[str(interaction.message.id)]["votes"] + votes: dict[int, list] = guild.polls[str(interaction.message.id)]["votes"] data = [ [f"Option {pos+1}", len(votes[str(pos)]), colours[pos]] for pos, _ in enumerate(interaction.message.embeds[0].fields) @@ -603,7 +603,7 @@ def _insert_vote_in_close_button( def _parse_votes_for( self, custom_id: str, field: "discord.embeds._EmbedFieldProxy" - ) -> Tuple[List[int], List[str]]: + ) -> tuple[list[int], list[str]]: """ Parses the votes for a poll or wyr """ @@ -624,7 +624,7 @@ def _is_room( self, where: SaveType, interaction: discord.Interaction, - votes: Dict[int, Tuple[List[int], List[str]]], + votes: dict[int, tuple[list[int], list[str]]], option: int, encrypted: str, poll: bool = None, @@ -659,12 +659,12 @@ async def _process_vote_with_compression( action: str, poll: bool, opt_votes: str, - ) -> Optional[Dict[int, Tuple[List[int], List[str]]]]: + ) -> dict[int, tuple[list[int], list[str]]] | None: """ If a None is returned, an error was sent and the execution should stop. Else the votes are returned. """ - votes: Dict[int, Tuple[List[int], List[str]]] = {} + votes: dict[int, tuple[list[int], list[str]]] = {} # This took me a bit to figure out when revisiting this # code. What is actually being stored here for each option # is: @@ -793,14 +793,14 @@ async def _process_vote_with_db( guild: Guild, option: int, poll: bool, - ) -> Dict[str, List[int]]: + ) -> dict[str, list[int]]: """ If the poll is saved in the database, this function processes the vote Only available for premium guilds """ - votes: Dict[int, List[int]] = guild.polls[str(interaction.message.id)]["votes"] + votes: dict[int, list[int]] = guild.polls[str(interaction.message.id)]["votes"] for pos, _ in enumerate(interaction.message.embeds[0].fields): if interaction.user.id in votes[str(pos)] and pos == option - 1: @@ -820,12 +820,12 @@ async def _process_vote_with_db( def _set_new_field_name_for_unsaved( self, field: "discord.embeds._EmbedFieldProxy", - votes: Dict[int, Tuple[List[int], List[str]]], + votes: dict[int, tuple[list[int], list[str]]], pos: int, interaction: discord.Interaction, poll: bool, option: int, - ) -> Tuple[str, str]: + ) -> tuple[str, str]: close_votes = re.findall( rf";{pos+1};(.*?)[;:]", interaction.message.components[0].children[-1].custom_id, @@ -873,10 +873,10 @@ def _set_new_field_name_for_unsaved( def _set_new_field_name_for_saved( self, field: "discord.embeds._EmbedFieldProxy", - votes: Dict[str, List[int]], + votes: dict[str, list[int]], pos: int, poll: bool, - ) -> Tuple[str, str]: + ) -> tuple[str, str]: num_of_votes = len(votes[str(pos)]) new_name = ( field.name[: -self.find_counter_start(field.name)] diff --git a/killua/cogs/games.py b/killua/cogs/games.py index b85327b9f..89522d742 100644 --- a/killua/cogs/games.py +++ b/killua/cogs/games.py @@ -8,7 +8,7 @@ from copy import deepcopy from aiohttp import ClientSession from urllib.parse import unquote -from typing import Union, List, Tuple, Literal, Callable, cast, Optional +from typing import Literal, Callable, cast from killua.bot import BaseBot from killua.utils.paginator import View @@ -52,7 +52,7 @@ def _will_exceed_interaction_limit(self, ctx: commands.Context, _max: int) -> bo return True async def _timeout( - self, players: List[discord.Member], data: List[Tuple[discord.Message, View]] + self, players: list[discord.Member], data: list[tuple[discord.Message, View]] ) -> None: """A way to handle a timeout of not responding to Killua in dms""" for x in players: @@ -63,11 +63,11 @@ async def _timeout( async def _wait_for_dm_response( self, - users: List[discord.Member], + users: list[discord.Member], create_view: Callable, - content: Union[str, discord.Embed], - ) -> Union[None, List[View]]: - data: List[Tuple[discord.Message, View]] = [] + content: str | discord.Embed, + ) -> None | list[View]: + data: list[tuple[discord.Message, View]] = [] for u in users: view = create_view(u.id) view.user = u @@ -225,7 +225,7 @@ async def create(self) -> None: self._create_embed() self._create_view() - async def send_single(self) -> Union[discord.Message, None]: + async def send_single(self) -> discord.Message | None: """Sends the embed and view and awaits a response""" if self.failed: return await self.ctx.send( @@ -244,8 +244,8 @@ async def send_single(self) -> Union[discord.Message, None]: self.result = self.view.value async def send_result_single( - self, view: Optional[PlayAgainButton] - ) -> Optional[discord.Message]: + self, view: PlayAgainButton | None + ) -> discord.Message | None: """Sends the result of the trivia and hands out jenny as rewards""" user = await User.new(self.ctx.author.id) @@ -275,7 +275,7 @@ async def send_result_single( view=view, ) - def _play_again_view(self, players: List[discord.Member]) -> View: + def _play_again_view(self, players: list[discord.Member]) -> View: """Creates a button that, if clicked by both players, automatically launches another game""" view = View(user_id=[x.id for x in players]) button = PlayAgainButton() @@ -302,7 +302,7 @@ async def play_single(self) -> None: await self.play_single() async def send_multi( - self, other: discord.Member, jenny: int, view: Optional[PlayAgainButton] + self, other: discord.Member, jenny: int, view: PlayAgainButton | None ) -> None: """Sends the questions in players dms""" self.other = other @@ -468,7 +468,7 @@ def __init__( self.other = other self.emotes = {0: ":page_facing_up:", -1: ":moyai:", 1: ":scissors:"} - def _get_options(self) -> List[discord.SelectOption]: + def _get_options(self) -> list[discord.SelectOption]: """Returns a new instance of the option list so it doesn't get mixed up when editing""" return [ discord.SelectOption(label="rock", value="-1", emoji="\U0001f5ff"), @@ -524,7 +524,7 @@ async def _eval_outcome( choice2: int, player1: discord.Member, player2: discord.Member, - view: Optional[PlayAgainButton], + view: PlayAgainButton | None, ) -> discord.Message: """Evaluates the outcome, informs the players and handles the points""" p1 = await User.new(player1.id) @@ -582,7 +582,7 @@ async def _eval_outcome( view=view, ) - def _play_again_view(self, players: List[discord.Member]) -> View: + def _play_again_view(self, players: list[discord.Member]) -> View: """Creates a button that, if clicked by both players, automatically launches another game""" view = View(user_id=[x.id for x in players]) button = PlayAgainButton() @@ -595,7 +595,7 @@ def create_view(self, user_id: int) -> View: view.add_item(select) return view - async def singleplayer(self) -> Union[None, discord.Message]: + async def singleplayer(self) -> None | discord.Message: """Handles the case of the user playing against the bot""" if await cast(BaseBot, self.ctx.bot)._dm_check(self.ctx.author) is False: return await self.ctx.send( @@ -633,7 +633,7 @@ async def singleplayer(self) -> Union[None, discord.Message]: else: await self.singleplayer() - async def multiplayer(self, replay: bool = False) -> Union[None, discord.Message]: + async def multiplayer(self, replay: bool = False) -> None | discord.Message: """Handles the case of the user playing against self.other user""" if not replay: if await cast(BaseBot, self.ctx.bot)._dm_check(self.ctx.author) is False: @@ -813,7 +813,7 @@ def _handle_reward(self) -> int: else 0 ) - def _assign_until_unique(self, already_assigned: List[int]) -> int: + def _assign_until_unique(self, already_assigned: list[int]) -> int: """Picks one random free spot to put the next number in""" r = random.randint(1, 25) if r in already_assigned: @@ -1015,7 +1015,7 @@ async def rps(self, ctx: commands.Context, user: discord.User, points: int = Non async def _topic_autocomplete( self, _: commands.Context, argument: str - ) -> List[discord.app_commands.Choice]: + ) -> list[discord.app_commands.Choice]: """The function to call to get the autocomplete for the trivia topic""" return [ discord.app_commands.Choice(name=i, value=i) @@ -1266,14 +1266,14 @@ async def gleaderboard( ) ) - all: List[dict] = await DB.teams.find( + all: list[dict] = await DB.teams.find( {} if where == "global" else {"id": {"$in": [m.id for m in ctx.guild.members]}} ).to_list(None) if game == GameOptions.rps: - top_5_pve: List[dict] = deepcopy( + top_5_pve: list[dict] = deepcopy( all ) # Not get this resorted in the code below top_5_pve.sort( @@ -1285,7 +1285,7 @@ async def gleaderboard( reverse=True, ) - top_5_pvp: List[dict] = all + top_5_pvp: list[dict] = all top_5_pvp.sort( key=lambda x: dict(x) .get("stats", {}) diff --git a/killua/cogs/help.py b/killua/cogs/help.py index 63fb0016e..64dae7bbc 100644 --- a/killua/cogs/help.py +++ b/killua/cogs/help.py @@ -1,5 +1,5 @@ import discord, os -from typing import List, Union, cast, Tuple +from typing import cast from discord.ext import commands from datetime import datetime from inspect import getsourcelines @@ -97,7 +97,7 @@ def _message_usage(self, command: commands.HybridCommand, prefix: str) -> str: else "```css\n" + prefix + command.usage + "\n```" ) - def _checks_info(self, command: commands.HybridCommand) -> Tuple[bool, bool, Union[bool, int]]: + def _checks_info(self, command: commands.HybridCommand) -> tuple[bool, bool, bool | int]: """Gets info about the checks of a command""" checks = command.checks @@ -226,7 +226,7 @@ async def handle_command( embed=embed, view=source_view, ephemeral=self.client.is_user_installed(ctx) ) - def find_category(self, string: str) -> Union[Category, None]: + def find_category(self, string: str) -> Category | None: for cat in Category: if cast(str, cat.value["name"]).lower() == string.lower(): return cat @@ -236,7 +236,7 @@ async def help_autocomplete( self, _: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Autocomplete for all cards""" raw = self.client.get_raw_formatted_commands() return [ diff --git a/killua/cogs/image_manipulation.py b/killua/cogs/image_manipulation.py index 01d922ebf..5f0dbba54 100644 --- a/killua/cogs/image_manipulation.py +++ b/killua/cogs/image_manipulation.py @@ -3,7 +3,7 @@ import re import io -from typing import Union, Any, List, Coroutine, Optional, Literal +from typing import Any, Coroutine, Literal from PIL import Image, ImageDraw, ImageChops from asyncio import wait_for, TimeoutError, CancelledError @@ -43,7 +43,7 @@ def _crop_to_circle(self, im: Image.Image) -> Image.Image: return im.copy() - def _create_frames(self, image: Image.Image) -> List[Image.Image]: + def _create_frames(self, image: Image.Image) -> list[Image.Image]: """Creates the frames for the spin, slightly rotating each frame""" res = [] for i in range(17): @@ -102,7 +102,7 @@ async def create_wtf_meme(self, url: str) -> io.BytesIO: return buffer async def _validate_input( - self, ctx: commands.Context, target: Optional[str] + self, ctx: commands.Context, target: str | None ) -> str: # a useful check that looks for what url to pass pxlapi """Finds an image url to use from a command""" if target: @@ -164,7 +164,7 @@ async def _validate_input( async def handle_command( self, ctx: commands.Context, - target: Union[discord.Member, discord.Emoji, str], + target: discord.Member | discord.Emoji | str, function: Coroutine, t: Any = None, censor: bool = False, @@ -227,7 +227,7 @@ async def handle_command( async def flag_autocomplete( self, _: commands.Context, current: str - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Returns a list of flags that match the current string since there are too many flags for it to use the options feature""" return [ discord.app_commands.Choice(name=i, value=i) diff --git a/killua/cogs/moderation.py b/killua/cogs/moderation.py index 9f8a1b9b2..18bb9bc2c 100644 --- a/killua/cogs/moderation.py +++ b/killua/cogs/moderation.py @@ -1,6 +1,5 @@ import discord from discord.ext import commands -from typing import List, Union from datetime import datetime from killua.bot import BaseBot @@ -19,7 +18,7 @@ def __init__(self, client: BaseBot): async def check_perms( self, ctx: commands.Context, member: discord.Member - ) -> Union[None, discord.Message]: + ) -> None | discord.Message: if member == ctx.me: return await ctx.send("Hey!", ephemeral=True) @@ -194,7 +193,7 @@ async def kick( async def shush_autocomplete( self, _: discord.Interaction, current: str - ) -> List[Choice[TimeConverter]]: + ) -> list[Choice[TimeConverter]]: """ Autocomplete for shush command """ diff --git a/killua/cogs/premium.py b/killua/cogs/premium.py index e2ef572a7..7f891c803 100644 --- a/killua/cogs/premium.py +++ b/killua/cogs/premium.py @@ -1,7 +1,7 @@ import discord from discord.ext import commands, tasks -from typing import List, Union, Literal +from typing import Literal from aiohttp import ClientSession from datetime import datetime @@ -23,7 +23,7 @@ class Patrons: - def __init__(self, patrons: List[dict]): + def __init__(self, patrons: list[dict]): self.patrons = patrons self.invalid = [x for x in self.patrons if x["discord"] is None] @@ -43,17 +43,17 @@ def __next__(self): # This is to check `if id in Patrons` class Patreon: """A class which returns all current patreons with the campain specified and their discord id or None""" - def __init__(self, session: ClientSession, token: str, campain_id: Union[str, int]): + def __init__(self, session: ClientSession, token: str, campain_id: str | int): self.session = session self.token = token self.campain_id = campain_id self.url = f"https://www.patreon.com/api/oauth2/v2/campaigns/{self.campain_id}/members?page%5B100%5D&include=currently_entitled_tiers%2Cuser&fields%5Bmember%5D=full_name%2Cis_follower%2Clast_charge_date%2Clast_charge_status%2Clifetime_support_cents%2Ccurrently_entitled_amount_cents%2Cpatron_status%2Cpledge_relationship_start&fields%5Buser%5D=social_connections&page%5Bcount%5D=100" - def _int_else(self, i: Union[str, None]) -> Union[int, None]: + def _int_else(self, i: str | None) -> int | None: """Turns a string into an integer if it exists, else returns None""" return int(i) if i else None - def _catch(self, d: dict) -> Union[str, None]: + def _catch(self, d: dict) -> str | None: """Tries to get the discord id from the response, if any step on the way fails it returns None""" try: return d["attributes"]["social_connections"]["discord"]["user_id"] @@ -69,7 +69,7 @@ async def _make_request(self, url: str) -> dict: ) return await res.json() - async def _paginate(self, data: dict, prev: List[dict]) -> List[dict]: + async def _paginate(self, data: dict, prev: list[dict]) -> list[dict]: """Paginates through all patreons and returns a list of all patreons""" if "links" in data.keys(): res = await self._make_request(data["links"]["next"]) @@ -78,15 +78,15 @@ async def _paginate(self, data: dict, prev: List[dict]) -> List[dict]: else: return prev - async def _get(self, data: dict) -> List[dict]: + async def _get(self, data: dict) -> list[dict]: """Gets a list of all discord ids of the patrons""" - prev: List[dict] = self._format_patrons(data) + prev: list[dict] = self._format_patrons(data) if "links" in data.keys(): return await self._paginate(data, prev) else: return prev - def _get_user_info(self, data: list, user: str) -> dict: + def _get_user_info(self, data: list[dict], user: str) -> dict: """Finds the relevant info by comparing user ids""" return next( ( @@ -98,8 +98,8 @@ def _get_user_info(self, data: list, user: str) -> dict: None, ) # Think this is stupid? Thank Python and Patreon - def _format_patrons(self, data: dict) -> List[dict]: - res: List[dict] = [] + def _format_patrons(self, data: dict) -> list[dict]: + res: list[dict] = [] for i in data["included"]: if i["type"] == "user": user = self._get_user_info(data["data"], i["id"]) @@ -140,7 +140,7 @@ async def on_ready(self): if self.invalid and not self.get_patrons.is_running(): self.get_patrons.start() - def _get_boosters(self) -> list: + def _get_boosters(self) -> list[int]: """Gets a list of all the boosters of the support server""" guild = self.client.get_guild(GUILD) if guild is None: @@ -148,7 +148,7 @@ def _get_boosters(self) -> list: return [] return [x.id for x in guild.members if BOOSTER_ROLE in [r.id for r in x.roles]] - def _get_differences(self, current: Patrons, saved: List[dict]) -> List[dict]: + def _get_differences(self, current: Patrons, saved: list[dict]) -> list[dict]: """Returns a list of dictionaries containing a user id and the badge to assign. If the badge is None, they will loose their premium badges""" boosters = self._get_boosters() new_patrons = [ @@ -176,7 +176,7 @@ def _get_differences(self, current: Patrons, saved: List[dict]) -> List[dict]: ] return [*new_patrons, *removed_patrons, *different_badges] - async def _assign_badges(self, diff: List[dict]) -> None: + async def _assign_badges(self, diff: list[dict]) -> None: """Assigns the changed badges to the users""" for d in diff: user = await User.new(d["discord"]) @@ -196,8 +196,8 @@ async def _assign_badges(self, diff: List[dict]) -> None: await user.set_badges(badges) def _get_subscription_entitlements( - self, all: List[discord.Entitlement] - ) -> List[discord.Entitlement]: + self, all: list[discord.Entitlement] + ) -> list[discord.Entitlement]: """Compares entitlement IDs to cached SKU IDs to determine which are subscriptions""" entitlements = [] for sku in self.client.cached_skus: @@ -217,7 +217,7 @@ def _get_subscription_entitlements( def _get_coresponding_consumable_sku( self, ent: discord.Entitlement - ) -> Union[discord.SKU, None]: + ) -> discord.SKU | None: """Gets the consumable SKU that corresponds to the entitlement""" for sku in self.client.cached_skus: if sku.type != discord.SKUType.consumable: diff --git a/killua/cogs/prometheus.py b/killua/cogs/prometheus.py index c53840a58..54469edd2 100644 --- a/killua/cogs/prometheus.py +++ b/killua/cogs/prometheus.py @@ -4,7 +4,7 @@ import logging from pymongo.errors import ServerSelectionTimeoutError from prometheus_client import start_http_server -from typing import cast, List, Dict +from typing import cast from psutil import virtual_memory, cpu_percent from killua.metrics import * @@ -30,7 +30,7 @@ def __init__(self, client: Bot, port: int = 8000): self.client = client self.port = port self.initial = False - self.api_previous: Dict[str, Dict[str, int]] = {} + self.api_previous: dict[str, dict[str, int]] = {} self.spam_previous: int = 0 if self.client.run_in_docker: @@ -129,7 +129,7 @@ async def init_gauges(self): await self.save_locales() # Update command stats - usage_data: Dict[str, int] = (await DB.const.find_one({"_id": "usage"}))[ + usage_data: dict[str, int] = (await DB.const.find_one({"_id": "usage"}))[ "command_usage" ] cmds = self.client.get_raw_formatted_commands() @@ -146,7 +146,7 @@ async def init_gauges(self): await self.update_api_stats() - def get_all_commands(self) -> List[commands.Command]: + def get_all_commands(self) -> list[commands.Command]: return self.client.get_raw_formatted_commands() def start_prometheus(self): diff --git a/killua/cogs/shop.py b/killua/cogs/shop.py index 10b3c9ad7..554671f44 100644 --- a/killua/cogs/shop.py +++ b/killua/cogs/shop.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from discord.ext import commands, tasks from random import randint, choice -from typing import Union, Tuple, List, Literal, Dict +from typing import Literal from pymongo.errors import ServerSelectionTimeoutError from killua.bot import BaseBot @@ -54,14 +54,14 @@ class Shop(commands.Cog): def __init__(self, client: BaseBot): self.client = client - self.cardname_cache: Dict[int, Tuple[str, str]] = {} + self.cardname_cache: dict[int, tuple[str, str]] = {} self.last_update = None async def _format_offers( - self, offers: list, reduced_item: int = None, reduced_by: int = None - ) -> List[Dict[str, str]]: + self, offers: list[int], reduced_item: int = None, reduced_by: int = None + ) -> list[dict[str, str]]: """Formats the offers for the shop""" - formatted: list = [] + formatted: list[dict[str, str]] = [] if reduced_item is not None and reduced_by is not None: # Past me made an error. if reduced_item is False if it is 0. # Needs to explicitly check if it is None @@ -83,7 +83,7 @@ async def _format_item( reduced_item: int = None, reduced_by: int = None, number: int = None, - ) -> Dict[str, str]: + ) -> dict[str, str]: """Formats a single item for the shop""" item = Card(offer) price = PRICES[item.rank] + ( @@ -112,7 +112,7 @@ async def cards_shop_update(self): # pragma: no cover # There have to be 4-5 shop items, inserted into the db as a list with the card numbers # the challenge is to create a balanced system with good items rare enough but not too rare try: - shop_items: list = [] + shop_items: list[int] = [] number_of_items = randint(3, 5) # How many items the shop has if randint(1, 100) > 95: # Add a S/A card to the shop @@ -389,7 +389,7 @@ async def card(self, ctx: commands.Context, item: str): """Buy a card from the shop with this command""" shop_data = await DB.const.find_one({"_id": "shop"}) - shop_items: list = shop_data["offers"] + shop_items: list[int] = shop_data["offers"] user = await User.new(ctx.author.id) try: @@ -455,7 +455,7 @@ async def card(self, ctx: commands.Context, item: str): async def lootbox_autocomplete( self, _: discord.Interaction, current: str - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """A function to autocomplete the lootbox name""" options = [] for lb in LOOTBOXES.values(): @@ -600,7 +600,7 @@ async def give(self, ctx: commands.Context): async def _validate( self, ctx: commands.Context, other: discord.Member - ) -> Union[discord.Message, Tuple[User, User]]: + ) -> discord.Message | tuple[User, User]: """Validates if someone is a bot or the author and returns a tuple of users if correct, else a message""" if other == ctx.author: return await ctx.send("You can't give yourself anything!") @@ -639,7 +639,7 @@ async def all_cards_autocomplete( self, interaction: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Autocomplete for all cards""" if not self.cardname_cache: for card in Card.raw: @@ -713,7 +713,7 @@ async def _card(self, ctx: commands.Context, other: discord.Member, card: str): async def all_lootboxes_autocomplete( self, interaction: discord.Interaction, current: str - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Autocomplete for all lootboxes""" name_boxes = [ (x, LOOTBOXES[x]["name"]) diff --git a/killua/cogs/small_commands.py b/killua/cogs/small_commands.py index 91b808f4b..334a0583b 100644 --- a/killua/cogs/small_commands.py +++ b/killua/cogs/small_commands.py @@ -5,7 +5,7 @@ import time from random import randint, choice import math -from typing import List, Literal +from typing import Literal from urllib.parse import quote @@ -348,7 +348,7 @@ async def vote(self, ctx: commands.Context): async def lang_autocomplete( self, _: commands.Context, current: str - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Returns a list of flags that match the current string since there are too many flags for it to use the options feature""" return [ discord.app_commands.Choice(name=i.title(), value=i) diff --git a/killua/cogs/tags.py b/killua/cogs/tags.py index 1afcefc6c..00de4474c 100644 --- a/killua/cogs/tags.py +++ b/killua/cogs/tags.py @@ -1,7 +1,7 @@ import discord from discord.ext import commands from datetime import datetime -from typing import List, Optional, cast, Union +from typing import cast import math from dataclasses import dataclass, field @@ -16,14 +16,14 @@ @dataclass class Tag: found: bool - tags: list = field(default_factory=list) - name: Optional[str] = None - created_at: Optional[datetime] = None - owner: Optional[int] = None - content: Optional[str] = None - uses: Optional[int] = None - guild_id: Optional[int] = None - indx: Optional[int] = None + tags: list[dict] = field(default_factory=list) + name: str | None = None + created_at: datetime | None = None + owner: int | None = None + content: str | None = None + uses: int | None = None + guild_id: int | None = None + indx: int | None = None @classmethod async def new(cls, guild_id: int, tag_name: str) -> "Tag": @@ -54,7 +54,7 @@ async def _sync(self) -> None: guild.tags = self.tags await guild._update_val("tags", self.tags) - async def update(self, key: str, value: Union[str, int, datetime]) -> None: + async def update(self, key: str, value: str | int | datetime) -> None: self.tags[self.indx][key] = value await self._sync() @@ -94,7 +94,7 @@ async def create(self, name: str, content: str, owner: int) -> None: @dataclass class Member: has_tags: bool - tags: list = field(default_factory=list) + tags: list[dict] = field(default_factory=list) @classmethod async def new(cls, user_id: int, guild_id: int) -> "Member": @@ -105,7 +105,7 @@ async def new(cls, user_id: int, guild_id: int) -> "Member": if user_id not in [r["owner"] for r in tag_list]: return Member(has_tags=False) - owned_tags: list = [] + owned_tags: list[list] = [] for x in tag_list: if x["owner"] == user_id: owned_tags.append([x["name"], x["uses"]]) @@ -136,7 +136,7 @@ def _init_menus(self) -> None: self.client.tree.add_command(menu) def _build_embed( - self, ctx: commands.Context, content: list, page: int, user: discord.User = None + self, ctx: commands.Context, content: list[str], page: int, user: discord.User = None ) -> discord.Embed: if len(content) - page * 10 + 10 > 10: @@ -164,7 +164,7 @@ async def tag_autocomplete( self, interaction: discord.Interaction, current: str, - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: """Returns a list of tags that match the message.""" guild = await Guild.new(interaction.guild.id) @@ -180,7 +180,7 @@ async def tag(self, _: commands.Context): ... @staticmethod - def _validate_tag_details(name: Optional[str], content: Optional[str]) -> Optional[str]: + def _validate_tag_details(name: str | None, content: str | None) -> str | None: if name and len(name) > 30: return "The tag title has too many characters!" @@ -194,7 +194,7 @@ def _validate_tag_details(name: Optional[str], content: Optional[str]) -> Option return "The tag content has too many characters!" @staticmethod - async def initial_new_tag_validation(name: str, guild: discord.Guild, db_guild: Guild, member_id: int) -> Optional[str]: + async def initial_new_tag_validation(name: str, guild: discord.Guild, db_guild: Guild, member_id: int) -> str | None: tag = await Tag.new(guild.id, name) if tag.found is not False: owner = guild.get_member(tag.owner) @@ -431,7 +431,7 @@ async def user(self, ctx: commands.Context, user: discord.Member): z = sorted( zip([x[1] for x in member.tags], [x[0] for x in member.tags]), reverse=True ) - g: list = [] + g: list[str] = [] for i in z: uses, name = i g.append(f"`{name}` with `{uses}` uses") diff --git a/killua/cogs/todo.py b/killua/cogs/todo.py index 6ff93815b..af5b4108f 100644 --- a/killua/cogs/todo.py +++ b/killua/cogs/todo.py @@ -2,7 +2,7 @@ from discord.ext import commands, tasks from datetime import datetime import math, re -from typing import Union, Optional, List, Literal, cast +from typing import Literal, cast from killua.bot import BaseBot from killua.static.enums import Category @@ -115,7 +115,7 @@ async def _build_embed( final_todos = l[-(len(l) - page * 10 + 10) :] async def assigned_users(t: Todo) -> str: - at: List[discord.User] = [] + at: list[discord.User] = [] for user in t.assigned_to: person = await self._get_user(user) at.append(person) @@ -150,7 +150,7 @@ async def assigned_users(t: Todo) -> str: return embed async def todo_info_embed_generator( - self, ctx: commands.Context, list_id: Union[int, str] + self, ctx: commands.Context, list_id: int | str ) -> discord.Message: """outsourcing big embed production 🛠""" try: @@ -194,7 +194,7 @@ async def todo_info_embed_generator( return await self.client.send_message(ctx, embed=embed) async def single_todo_info_embed_generator( - self, ctx: commands.Context, list_id: Union[int, str], task_id: int + self, ctx: commands.Context, list_id: int | str, task_id: int ) -> discord.Message: """outsourcing big embed production 🛠""" try: @@ -259,7 +259,7 @@ async def single_todo_info_embed_generator( embed.set_thumbnail(url=todo_list.thumbnail) return await self.client.send_message(ctx, embed=embed) - async def _edit_check(self, ctx: commands.Context) -> Union[None, TodoList]: + async def _edit_check(self, ctx: commands.Context) -> None | TodoList: """A generic check before every command that edits a todo list property""" try: list_id = editing[ctx.author.id] @@ -293,7 +293,7 @@ async def create( name: str, status: Literal["public", "private"], delete_when_done: Literal["yes", "no"], - custom_id: Optional[str] = None, + custom_id: str | None = None, ): """Lets you create your todo list in an interactive menu""" @@ -457,7 +457,7 @@ async def edit(self, ctx: commands.Context, list_id: str): async def _update_name( self, todo_list: TodoList, new_name: str - ) -> Optional[str]: + ) -> str | None: """Updates the name of a todo list""" if len(new_name) > 30: return "Name can't be longer than 30 characters" @@ -466,7 +466,7 @@ async def _update_name( async def _update_custom_id( self, todo_list: TodoList, new_custom_id: str, user: User - ) -> Optional[str]: + ) -> str | None: """Updates the custom id of a todo list""" if not user.is_premium: return "You need to be a premium user to use custom ids" @@ -489,7 +489,7 @@ async def _update_custom_id( async def _update_color( self, todo_list: TodoList, color: str - ) -> Optional[str]: + ) -> str | None: """Updates the color of a todo list""" if not todo_list.has_addon("color"): return "You can't customize this property, you need to buy it in the shop" @@ -505,7 +505,7 @@ async def _update_color( async def _update_thumbnail( self, todo_list: TodoList, thumbnail: str - ) -> Optional[str]: + ) -> str | None: """Updates the thumbnail of a todo list""" if not todo_list.has_addon("thumbnail"): return "You can't customize this property, you need to buy it in the shop" @@ -524,7 +524,7 @@ async def _update_thumbnail( async def _update_description( self, todo_list: TodoList, description: str - ) -> Optional[str]: + ) -> str | None: """Updates the description of a todo list""" if not todo_list.has_addon("description"): return "You can't customize this property, you need to buy it in the shop" @@ -549,13 +549,13 @@ async def _update_description( async def update( self, ctx: commands.Context, - name: Optional[str] = None, - status: Optional[Literal["private", "public"]] = None, - delete_when_done: Optional[Literal["yes", "no"]] = None, - custom_id: Optional[str] = None, - color: Optional[str] = None, - thumbnail: Optional[str] = None, - description: Optional[str] = None, + name: str | None = None, + status: Literal["private", "public"] | None = None, + delete_when_done: Literal["yes", "no"] | None = None, + custom_id: str | None = None, + color: str | None = None, + thumbnail: str | None = None, + description: str | None = None, ): """Update your todo list with this command (Only in editor mode)""" @@ -648,7 +648,7 @@ async def remove(self, ctx: commands.Context, todo_numbers: commands.Greedy[int] async def marked_as_autocomplete( self, _: discord.Interaction, current: str - ) -> List[discord.app_commands.Choice[str]]: + ) -> list[discord.app_commands.Choice[str]]: return [ discord.app_commands.Choice(name=x, value=x) for x in ["done", "in progress", "high priority", "low priority"] @@ -713,7 +713,7 @@ async def mark(self, ctx: commands.Context, todo_number: int, *, marked_as: str) async def due_in_autocomplete( self, _: discord.Interaction, current: str - ) -> List[discord.app_commands.Choice[TimeConverter]]: + ) -> list[discord.app_commands.Choice[TimeConverter]]: """ Autocomplete for the due in parameter """ @@ -733,7 +733,7 @@ async def add( ctx: commands.Context, *, text: str, - due_in: Optional[TimeConverter] = None, + due_in: TimeConverter | None = None, ): """Add a todo to your list, *yay, more work* (Only in editor mode)""" todo_list = await self._edit_check(ctx) diff --git a/killua/cogs/web_scraping.py b/killua/cogs/web_scraping.py index 6e8d9554e..b783ac619 100644 --- a/killua/cogs/web_scraping.py +++ b/killua/cogs/web_scraping.py @@ -4,7 +4,6 @@ from bs4 import BeautifulSoup from html import escape from json import loads -from typing import List, Union from pypxl import PxlClient from asyncio import wait_for, TimeoutError from urllib.parse import unquote, quote @@ -52,7 +51,7 @@ def _init_menus(self) -> None: for menu in menus: self.client.tree.add_command(menu) - def book_buying_links(self, book: dict) -> List[str]: + def book_buying_links(self, book: dict) -> list[str]: """Returns a list of links where the book can be bought""" available_to_buy = [] if "ia" in book: @@ -179,7 +178,7 @@ async def make_embed(page, embed: discord.Embed, pages): return await Paginator(ctx, embeds, func=make_embed).start() - async def _get_token(self, query: str) -> Union[str, None]: + async def _get_token(self, query: str) -> str | None: """Gets a new token to be used in the image search""" try: res = await self.client.session.get( @@ -194,7 +193,7 @@ async def _get_token(self, query: str) -> Union[str, None]: except Exception: return - async def get_duckduckgo_images(self, query: str) -> Union[List[str], None]: + async def get_duckduckgo_images(self, query: str) -> list[str] | None: token = await self._get_token(query) if not token: @@ -221,7 +220,7 @@ async def get_duckduckgo_images(self, query: str) -> Union[List[str], None]: return [r["image"] for r in results if r["image"]] - async def get_soup(self, url) -> Union[int, BeautifulSoup]: + async def get_soup(self, url) -> int | BeautifulSoup: """Make request and return BeautifulSoup object""" header = { "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.134 Safari/537.36" @@ -234,7 +233,7 @@ async def get_soup(self, url) -> Union[int, BeautifulSoup]: return BeautifulSoup(await response.text(), "html.parser") - async def get_bing_images(self, query: str) -> List[str]: + async def get_bing_images(self, query: str) -> list[str]: """Make the request format it correctly and return the list of images""" BASE_URL = "http://www.bing.com/images/search?q={}&FORM=HDRSC2" diff --git a/killua/static/cards.py b/killua/static/cards.py index d12aa2792..5a7bac393 100644 --- a/killua/static/cards.py +++ b/killua/static/cards.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from discord.ext import commands from datetime import datetime -from typing import List, cast +from typing import cast from .constants import ( INDESTRUCTIBLE, @@ -215,7 +215,7 @@ async def exec(self) -> None: author = await User.new(self.ctx.author.id) await author.remove_card(self.id) - users: List[discord.Member] = [] + users: list[discord.Member] = [] stolen_cards = [] async for message in self.ctx.channel.history(limit=20): diff --git a/killua/static/constants.py b/killua/static/constants.py index 345b0ceaf..316df9001 100644 --- a/killua/static/constants.py +++ b/killua/static/constants.py @@ -4,7 +4,7 @@ from datetime import datetime from pymongo import AsyncMongoClient from pymongo.asynchronous.collection import AsyncCollection -from typing import Any, Callable, TypeVar, Generic, Union, Dict, List, Tuple +from typing import Any, Callable, TypeVar, Generic from killua.utils.test_db import TestingDatabase as Database import killua.args as args @@ -50,19 +50,19 @@ def __init__(self): self._DB = CLUSTER["Killua"] @DBProperty - def teams(self) -> Union[AsyncCollection, Database]: + def teams(self) -> AsyncCollection | Database: return self._DB["teams"] if args.Args.test is None else Database("teams") @DBProperty - def guilds(self) -> Union[AsyncCollection, Database]: + def guilds(self) -> AsyncCollection | Database: return self._DB["guilds"] if args.Args.test is None else Database("guilds") @DBProperty - def todo(self) -> Union[AsyncCollection, Database]: + def todo(self) -> AsyncCollection | Database: return self._DB["todo"] if args.Args.test is None else Database("todo") @DBProperty - def const(self) -> Union[AsyncCollection, Database]: + def const(self) -> AsyncCollection | Database: if args.Args.test is not None: db = Database("const") if not DB._test_const_seeded: @@ -75,13 +75,13 @@ def const(self) -> Union[AsyncCollection, Database]: return self._DB["const"] @DBProperty - def APIstats(self) -> Union[AsyncCollection, Database]: + def APIstats(self) -> AsyncCollection | Database: return ( self._DB["api-stats"] if args.Args.test is None else Database("api-stats") ) @DBProperty - def news(self) -> Union[AsyncCollection, Database]: + def news(self) -> AsyncCollection | Database: return self._DB["news"] if args.Args.test is None else Database("news") @@ -834,20 +834,7 @@ def file( } -LOOTBOXES: Dict[ - int, - Dict[ - str, - Union[ - str, - int, - bool, - Dict[ - str, Union[Dict[int, int], Dict[str, List[str]], Tuple[int], List[int]] - ], - ], - ], -] = { +LOOTBOXES: dict[int, dict[str, str | int | bool | dict[str, dict[int, int] | dict[str, list[str]] | tuple[int] | list[int]]]] = { 1: { "name": "Standard Box", "price": 250, diff --git a/killua/tests/README.md b/killua/tests/README.md index e10f81796..a7ca0b4f9 100644 --- a/killua/tests/README.md +++ b/killua/tests/README.md @@ -117,7 +117,7 @@ Killua uses Discord UI in two different ways. Tests mirror that split instead of Both paths funnel replies into the same place: `context.result.message` (content, embeds, components), so assertions stay identical to non-interactive commands. -#### End-to-end flow (Path A — command-owned view) +#### End-to-end flow (Path A: command-owned view) Typical case: `cards use` confirm (1026), defense select, paginator, `actions settings` select. @@ -157,7 +157,7 @@ with respond_to_view(self.base_context, press_save): assert "saved" in self.base_context.result.message.content ``` -**Confirm / cancel** — reuse `Testing.press_confirm` / `press_cancel` as the `respond_to_view` callback (`cards_use_spells.py`, shop buy): +**Confirm / cancel:** reuse `Testing.press_confirm` / `press_cancel` as the `respond_to_view` callback (`cards_use_spells.py`, shop buy): ```py from ..harnesses import respond_to_view @@ -167,7 +167,7 @@ with respond_to_view(self.base_context, Testing.press_confirm): await invoke_use(self, 1026) ``` -**Paginator** — do not hand-roll `custom_id` strings; use `press_paginator_button` so the real `Buttons` callback runs: +**Paginator:** do not hand-roll `custom_id` strings; use `press_paginator_button` so the real `Buttons` callback runs: ```py from ..harnesses import embed_footer_page, press_paginator_button @@ -183,7 +183,7 @@ after = embed_footer_page(self.base_context.result.message.embeds[0]) assert after[0] == before[0] + 1 ``` -**Defense select** (attack flow waits on target’s view) — `spell_use.respond_defense_with_spell` finds the `Select` and passes `data={"values": [str(spell_id)]}`: +**Defense select** (attack flow waits on target’s view): `spell_use.respond_defense_with_spell` finds the `Select` and passes `data={"values": [str(spell_id)]}`: ```py from ..harnesses import respond_defense_with_spell, run_attack_against_defender @@ -194,7 +194,7 @@ await run_attack_against_defender(self, defense_id=1003, use_defense=True) Helpers: `harnesses/views.py` (`find_button`, `find_select`), `harnesses/context.py` (`respond_to_view` context manager restores the previous callback). -#### End-to-end flow (Path B — cog `on_interaction`) +#### End-to-end flow (Path B: cog `on_interaction`) Poll and WYR votes are **not** wired through `view.wait()` on the command that created the message. `Events.on_interaction` reads `interaction.data["custom_id"]`, edits the embed, and may write `guild.polls`. Tests build a **message-shaped fixture** (embed + fake component rows) and call the listener directly. @@ -242,7 +242,7 @@ tail = encrypted_tail_on_button(updated_cid, "poll:opt-1:") assert len(tail) > 0 ``` -`MockComponentInteraction` (`harnesses/interaction.py`) implements enough of `discord.Interaction` for listeners: `type`, `data`, `user`, `message`, `response.send_message` / `edit_message`, `followup`, and `original_response`. It is **not** used for Path A view callbacks — those use `ArgumentInteraction` in `types/interaction.py`, which ties `response.send_message` back to `context.send` and keeps `current_view` in sync. +`MockComponentInteraction` (`harnesses/interaction.py`) implements enough of `discord.Interaction` for listeners: `type`, `data`, `user`, `message`, `response.send_message` / `edit_message`, `followup`, and `original_response`. It is **not** used for Path A view callbacks. Those use `ArgumentInteraction` in `types/interaction.py`, which ties `response.send_message` back to `context.send` and keeps `current_view` in sync. #### Path A in DMs (`member_dm`) @@ -272,8 +272,8 @@ See [COVERAGE_AUDIT.md](COVERAGE_AUDIT.md) for scope, gaps, and command-scenario ## Coverage audit and games DM notes - **Living matrix**: [COVERAGE_AUDIT.md](COVERAGE_AUDIT.md) lists games / cards / todo / economy / moderation coverage and **exit criteria** for high-value commands. -- **Games — DM flows**: use [`harnesses.member_dm.patch_member_rps_select`](harnesses/member_dm.py) on `Member.send` so `_wait_for_dm_response` completes via real `RpsSelect` callbacks (see `games.py` PvP/PvE tests). Do not stub `_wait_for_dm_response`. -- **Cards spell `use`**: import from [`harnesses`](harnesses/) — `invoke_use`, `assert_steal_succeeded`, `setup_met_view_spell`, `respond_to_view`, etc. See `groups/cards_use_spells.py`. +- **Games, DM flows:** use [`harnesses.member_dm.patch_member_rps_select`](harnesses/member_dm.py) on `Member.send` so `_wait_for_dm_response` completes via real `RpsSelect` callbacks (see `games.py` PvP/PvE tests). Do not stub `_wait_for_dm_response`. +- **Cards spell `use`:** import from [`harnesses`](harnesses/) (`invoke_use`, `assert_steal_succeeded`, `setup_met_view_spell`, `respond_to_view`, etc.). See `groups/cards_use_spells.py`. - **Poll / WYR votes**: use [`harnesses/poll_wyr.py`](harnesses/poll_wyr.py) to build messages with 5+ embed voters and assert encrypted `custom_id` tails or premium `guild.polls` updates via `Events.on_interaction`. - **DM confirms** (e.g. todo invite): [`harnesses/dm_view.patch_user_confirm_dm`](harnesses/dm_view.py). diff --git a/killua/tests/__init__.py b/killua/tests/__init__.py index bab4c1984..28fdd8047 100644 --- a/killua/tests/__init__.py +++ b/killua/tests/__init__.py @@ -1,12 +1,16 @@ import json import logging import sys +from datetime import datetime +from typing import TYPE_CHECKING, Any from . import config from .groups import tests from .types import Bot from ..static.enums import PrintColors -from datetime import datetime + +if TYPE_CHECKING: + from .types import TestResult # CAREFUL. This is a fairly hacky fix as assertion erros still get printed even though they are caught for some reason. @@ -27,7 +31,7 @@ async def _close_test_bot_session() -> None: await session.close() -def _exit_code_for_result(tr) -> int: +def _exit_code_for_result(tr: "TestResult") -> int: return 1 if (tr.failed or tr.errored) else 0 @@ -36,7 +40,7 @@ def _print_json_report(payload: dict, json_output: bool) -> None: print(json.dumps(payload, indent=2), flush=True) -def _group_name(group) -> str: +def _group_name(group: type) -> str: return group.__name__.replace("Testing", "") @@ -83,10 +87,10 @@ def _log_run_heading(heading: str) -> None: logging.info(PrintColors.OKCYAN + heading + PrintColors.ENDC) -async def run_tests(args, *, json_output: bool = False) -> int: +async def run_tests(args: list[str] | None, *, json_output: bool = False) -> int: # sys.stderr = DevMod() - async def _test_prefix(*_): + async def _test_prefix(*_: Any) -> list[str]: return ["mention1", "mention2", "k!"] Bot.command_prefix = _test_prefix diff --git a/killua/tests/groups/actions.py b/killua/tests/groups/actions.py index 7d5cc9429..9f3fc4e9b 100644 --- a/killua/tests/groups/actions.py +++ b/killua/tests/groups/actions.py @@ -1,17 +1,20 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.actions import Actions, AnimeAsset, ArtistAsset +from __future__ import annotations -from random import randrange, randint from asyncio import create_task, wait +from random import randrange, randint +from typing import Any from unittest.mock import patch +from ..types import ArgumentInteraction, Bot, Context, DiscordMember, Message from ..types.utils import get_random_discord_id +from ...utils.classes import User +from ..testing import Testing, test +from ...cogs.actions import Actions, AnimeAsset, ArtistAsset + from ..harnesses import MockComponentInteraction -def _embed0_actions(message): +def _embed0_actions(message: Message) -> Any | None: raw = message.embeds if isinstance(raw, list) and raw: return raw[-1] diff --git a/killua/tests/groups/api.py b/killua/tests/groups/api.py index cb428072a..efce0f564 100644 --- a/killua/tests/groups/api.py +++ b/killua/tests/groups/api.py @@ -6,11 +6,9 @@ from io import BytesIO from unittest.mock import AsyncMock, MagicMock, patch -import discord -from discord.ext import commands from PIL import Image -from ..testing import Testing, test, collect_test_classes +from ..testing import Testing, test, collect_test_classes, expect_raises from ..types import Bot from ...cogs.api import IPCRoutes, NewsMessage from ...static.constants import DB, NEWS_CHANNEL, POST_CHANNEL, UPDATE_CHANNEL @@ -98,11 +96,8 @@ async def invalid_news_type_ping_raises(self) -> None: "timestamp": datetime.now(), }, ) - try: + async with expect_raises(ValueError): _ = msg.relevant_ping - assert False - except ValueError: - pass @test async def from_data_round_trip(self) -> None: @@ -126,11 +121,8 @@ async def from_data_round_trip(self) -> None: @test async def from_id_missing_raises(self) -> None: DB.news.db["news"] = [] - try: + async with expect_raises(ValueError): await NewsMessage.from_id(Bot, "missing") - assert False - except ValueError: - pass @test async def from_id_returns_instance(self) -> None: @@ -304,11 +296,8 @@ async def news_save_draft(self) -> None: async def news_delete_missing_raises(self) -> None: ipc = self.cog DB.news.db["news"] = [] - try: + async with expect_raises(ValueError): await ipc.news_delete({"news_id": "missing"}) - assert False, "expected ValueError" - except ValueError: - pass @test async def news_delete_with_message(self) -> None: @@ -408,11 +397,8 @@ async def register_login_first_time(self) -> None: @test async def user_info_requires_id(self) -> None: ipc = self.cog - try: + async with expect_raises(ValueError): await ipc.user_info({}) - assert False - except ValueError: - pass @test async def commands_payload(self) -> None: @@ -442,11 +428,8 @@ async def user_get_basic_details_fetch_fallback(self) -> None: @test async def user_get_basic_details_invalid_id(self) -> None: ipc = self.cog - try: + async with expect_raises(ValueError): await ipc.user_get_basic_details({"user_id": "not-a-number"}) - assert False - except ValueError: - pass @test async def news_save_publishes_message(self) -> None: @@ -669,6 +652,8 @@ async def delete_discord_message(self) -> None: class _MockTopggResponse: + """Minimal aiohttp response stub for Top.gg ``session.request`` mocks.""" + def __init__(self, status: int = 200, body: str = "") -> None: self.status = status self._body = body @@ -684,13 +669,17 @@ async def __aexit__(self, *args) -> None: def _mock_topgg_session(session, *, status: int = 200, body: str = "") -> MagicMock: + """Replace ``session.request`` and return the mock for assertion.""" mock_request = MagicMock(return_value=_MockTopggResponse(status, body)) session.request = mock_request return mock_request class TopggAnnouncementTests(_ApiTests): - def _news_item(self, *, news_type="news", title="Launch", content="Body text here"): + def _news_item( + self, *, news_type: str = "news", title: str = "Launch", content: str = "Body text here" + ) -> dict: + """Minimal published news document for Top.gg announcement tests.""" return { "_id": "topgg1", "title": title, diff --git a/killua/tests/groups/bot_cov.py b/killua/tests/groups/bot_cov.py index 7b92f3234..17bde88da 100644 --- a/killua/tests/groups/bot_cov.py +++ b/killua/tests/groups/bot_cov.py @@ -3,7 +3,7 @@ from __future__ import annotations from io import BytesIO -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock from PIL import Image diff --git a/killua/tests/groups/cards.py b/killua/tests/groups/cards.py index a0f74894c..fc8c25d57 100644 --- a/killua/tests/groups/cards.py +++ b/killua/tests/groups/cards.py @@ -1,17 +1,18 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.cards import Cards -from ...static.cards import Card as IndividualCard -from ...utils.classes.card import Card -from ...utils.paginator import Buttons -from ...static.constants import PRICES, DEF_SPELLS, VIEW_DEF_SPELLS +from __future__ import annotations from random import randint from math import ceil from datetime import datetime, timedelta from unittest.mock import patch +from ..types import DiscordMember, Message, random_date +from ...utils.classes import User +from ..testing import Testing, test +from ...cogs.cards import Cards +from ...utils.classes.card import Card +from ...utils.paginator import Buttons +from ...static.constants import PRICES, DEF_SPELLS, VIEW_DEF_SPELLS + from ..fixtures import ensure_test_cards from ..harnesses import embed_footer_page, press_paginator_button diff --git a/killua/tests/groups/cards_use_spells.py b/killua/tests/groups/cards_use_spells.py index 156f84d75..0ab2baca8 100644 --- a/killua/tests/groups/cards_use_spells.py +++ b/killua/tests/groups/cards_use_spells.py @@ -5,10 +5,9 @@ from datetime import datetime from unittest.mock import patch -from ...static.constants import FREE_SLOTS, INDESTRUCTIBLE +from ...static.constants import FREE_SLOTS from ...utils.classes import User from ...utils.classes.card import Card -from ...utils.paginator import Buttons from ..harnesses import ( ATTACK_TIMEOUT_FRAGMENT, DEFAULT_ATTACK_SPELL, @@ -405,7 +404,7 @@ async def full_free_slots(self) -> None: for i in range(FREE_SLOTS): try: await user.add_card(1000 + (i % 50)) - except Exception: + except Exception: # inventory full or duplicate slot — stop seeding break await invoke_use(self, 1032) assert_content_contains(self.base_context, "don't have any space") diff --git a/killua/tests/groups/deep_coverage.py b/killua/tests/groups/deep_coverage.py index 3363b5fa0..f6297a311 100644 --- a/killua/tests/groups/deep_coverage.py +++ b/killua/tests/groups/deep_coverage.py @@ -2,10 +2,8 @@ from __future__ import annotations -from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch -import discord from discord.ext import commands from ..testing import Testing, test, collect_test_classes @@ -13,9 +11,7 @@ from ...cogs.dev import Dev from ...cogs.events import Events from ...cogs.shop import Shop -from ...static.constants import DB from ...utils.checks import check -from ...utils.classes import User, Guild class TestingDeep(Testing): @@ -25,6 +21,7 @@ def __init__(self) -> None: if not TestingDeep._menus_registered: TestingDeep._menus_registered = True else: + # Shop menu registration is global; skip re-init on subsequent group runs. Shop._init_menus = lambda self: None super().__init__(cog=Shop) diff --git a/killua/tests/groups/dev.py b/killua/tests/groups/dev.py index 6e3b96c02..21b0702f2 100644 --- a/killua/tests/groups/dev.py +++ b/killua/tests/groups/dev.py @@ -1,12 +1,13 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.dev import Dev -from ...static.constants import DB, INFO +from __future__ import annotations from datetime import datetime from unittest.mock import AsyncMock +from ...utils.classes import User +from ..testing import Testing, test +from ...cogs.dev import Dev +from ...static.constants import DB, INFO + class TestingDev(Testing): requires_command = True diff --git a/killua/tests/groups/economy.py b/killua/tests/groups/economy.py index fd6679f26..ad1c1bd31 100644 --- a/killua/tests/groups/economy.py +++ b/killua/tests/groups/economy.py @@ -1,13 +1,15 @@ -from ..types import * -from ...utils.classes import * +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +from ..types import ArgumentInteraction +from ...utils.classes import User from ..testing import Testing, test from ...cogs.economy import Economy from ...static.constants import LOOTBOXES, BOOSTERS from ...utils.classes.guild import Guild as KilluaGuild -from datetime import datetime, timedelta -from unittest.mock import patch - from ...utils.classes import lootbox as lootbox_mod diff --git a/killua/tests/groups/games.py b/killua/tests/groups/games.py index d0d0c1935..ed7460374 100644 --- a/killua/tests/groups/games.py +++ b/killua/tests/groups/games.py @@ -1,20 +1,23 @@ -from ..types import * -from ...utils.classes import * +from __future__ import annotations + +from unittest.mock import AsyncMock, patch +from typing import Any + +from ..types import ArgumentInteraction, DiscordMember +from ...utils.classes import User from ..testing import Testing, test from ...cogs.games import Games -from ...static.constants import TRIVIA_TOPICS, DB from ...utils.test_db import TestingDatabase from ..types.member import TestingMember -from unittest.mock import AsyncMock, patch -def _seed_teams(docs: list) -> None: +def _seed_teams(docs: list[dict]) -> None: TestingDatabase.db["teams"] = [] for d in docs: TestingDatabase.db["teams"].append(d) -def _last_embed_title_description(message): +def _last_embed_title_description(message) -> tuple[str, str]: raw = message.embeds embed = None if isinstance(raw, list) and raw: @@ -27,7 +30,7 @@ def _last_embed_title_description(message): return embed.title or "", embed.description or "" -async def _instant_sleep(*_a, **_k): +async def _instant_sleep(*_a: Any, **_k: Any) -> None: return None diff --git a/killua/tests/groups/help.py b/killua/tests/groups/help.py index 0dab665fb..99318eebc 100644 --- a/killua/tests/groups/help.py +++ b/killua/tests/groups/help.py @@ -1,8 +1,8 @@ +from __future__ import annotations + from types import SimpleNamespace from unittest.mock import patch -from ..types import * -from ...utils.classes import * from ..testing import Testing, test from ...cogs.help import HelpCommand, HelpPaginator from ...utils.classes.guild import Guild as KilluaGuild diff --git a/killua/tests/groups/image_manipulation.py b/killua/tests/groups/image_manipulation.py index 7f6d2fe5f..64841c4d8 100644 --- a/killua/tests/groups/image_manipulation.py +++ b/killua/tests/groups/image_manipulation.py @@ -1,10 +1,11 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.image_manipulation import ImageManipulation +from __future__ import annotations -from unittest.mock import AsyncMock import io +from typing import Any +from unittest.mock import AsyncMock + +from ..testing import Testing, test +from ...cogs.image_manipulation import ImageManipulation class MockPxlResult: @@ -17,7 +18,7 @@ def convert_to_ioBytes(self): return io.BytesIO(b"fake_image_data") -def _normalize_embeds(message): +def _normalize_embeds(message) -> list[Any]: e = getattr(message, "embeds", None) or [] if isinstance(e, tuple) and e: inner = e[0] diff --git a/killua/tests/groups/moderation.py b/killua/tests/groups/moderation.py index 4d4aa3898..1cf54387c 100644 --- a/killua/tests/groups/moderation.py +++ b/killua/tests/groups/moderation.py @@ -1,12 +1,13 @@ +from __future__ import annotations + from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch import discord from discord.ext import commands -from ..types import * +from ..types import DiscordMember, DiscordUser, Role from ..types.permissions import Permissions -from ...utils.classes import * from ..testing import Testing, test from ...cogs.moderation import Moderation from ...utils.classes.guild import Guild as KilluaGuild diff --git a/killua/tests/groups/premium.py b/killua/tests/groups/premium.py index 016932120..70cdc0fd0 100644 --- a/killua/tests/groups/premium.py +++ b/killua/tests/groups/premium.py @@ -1,14 +1,15 @@ -from ..types import * -from ...utils.classes import * +from __future__ import annotations + +from datetime import datetime, timedelta + +from ...utils.classes import User from ..testing import Testing, test from ...cogs.premium import Premium from ...utils.classes.guild import Guild as KilluaGuild -from ...static.constants import DB, LOOTBOXES, PATREON_TIERS - -from datetime import datetime, timedelta +from ...static.constants import DB, PATREON_TIERS -def _reset_guild_state(): +def _reset_guild_state() -> None: KilluaGuild.cache.clear() DB.guilds.db["guilds"] = [] diff --git a/killua/tests/groups/prometheus_cov.py b/killua/tests/groups/prometheus_cov.py index 434558564..b3e1d3591 100644 --- a/killua/tests/groups/prometheus_cov.py +++ b/killua/tests/groups/prometheus_cov.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch from ..testing import Testing, test, collect_test_classes from ..types import Bot diff --git a/killua/tests/groups/shop.py b/killua/tests/groups/shop.py index ca9b03b6b..a513368c6 100644 --- a/killua/tests/groups/shop.py +++ b/killua/tests/groups/shop.py @@ -1,5 +1,12 @@ -from ..types import * -from ...utils.classes import * +from __future__ import annotations + +import copy +from datetime import datetime +from unittest.mock import patch, AsyncMock + +from ..types import ArgumentInteraction +from ...utils.classes import User +from ...utils.classes.todo import TodoList from ..testing import Testing, test from ...static.constants import editing, DB from ...cogs.shop import Shop @@ -7,10 +14,6 @@ from ..types.member import TestingMember from ...static.cards import Card -import copy -from datetime import datetime -from unittest.mock import patch, AsyncMock - from ..harnesses import embed_footer_page, press_paginator_button from ..fixtures import ensure_test_cards @@ -175,7 +178,7 @@ async def _next(ctx): assert fp[0] == 2 and fp[1] >= 2, fp -def _seed_shop_offers_sync(card_ids: list) -> None: +def _seed_shop_offers_sync(card_ids: list[int]) -> None: """Mutate shared test DB.const collection (same store as DB.const property).""" coll = DB.const.db.setdefault("const", []) for doc in coll: @@ -187,7 +190,7 @@ def _seed_shop_offers_sync(card_ids: list) -> None: coll.append({"_id": "shop", "offers": card_ids, "reduced": None, "log": []}) -async def _seed_shop_offers(card_ids: list) -> None: +async def _seed_shop_offers(card_ids: list[int]) -> None: _seed_shop_offers_sync(card_ids) @@ -292,8 +295,6 @@ def _buy_subcommand(self, name: str): @test async def buy_space_insufficient_jenny(self) -> None: - from ...utils.classes.todo import TodoList - todo_list = await TodoList.create( owner=self.base_author.id, title="Poor list", @@ -313,8 +314,6 @@ async def buy_space_insufficient_jenny(self) -> None: @test async def buy_space_cancel(self) -> None: - from ...utils.classes.todo import TodoList - todo_list = await TodoList.create( owner=self.base_author.id, title="Cancel list", @@ -407,8 +406,6 @@ async def buy_card_success_deducts_jenny(self) -> None: @test async def buy_space_confirm_adds_spots(self) -> None: """One ConfirmButton on ctx.send — press confirm via respond_to_view.""" - from ...utils.classes.todo import TodoList - todo_list = await TodoList.create( owner=self.base_author.id, title="Shop list", diff --git a/killua/tests/groups/small_commands.py b/killua/tests/groups/small_commands.py index fcfd3b25c..ca8befaee 100644 --- a/killua/tests/groups/small_commands.py +++ b/killua/tests/groups/small_commands.py @@ -1,14 +1,22 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.small_commands import SmallCommands +from __future__ import annotations +from typing import Any from unittest.mock import MagicMock, AsyncMock, patch import discord +from ..types import DiscordMember +from ..testing import Testing, test +from ...cogs.small_commands import SmallCommands + +from ..harnesses import ListenerFakeButton, ListenerFakeRow + +# Backwards-compatible aliases for listener-style tests in this module. +_ListenerFakeButton = ListenerFakeButton +_ListenerFakeRow = ListenerFakeRow + -def _embed0(message): +def _embed0(message) -> Any | None: raw = message.embeds if isinstance(raw, list) and raw: return raw[0] @@ -19,13 +27,6 @@ def _embed0(message): return raw[0] if raw else None -from ..harnesses import ListenerFakeButton, ListenerFakeRow - -# Backwards-compatible aliases for listener-style tests in this module. -_ListenerFakeButton = ListenerFakeButton -_ListenerFakeRow = ListenerFakeRow - - class TestingSmallCommands(Testing): requires_command = True _menus_registered = False diff --git a/killua/tests/groups/tags.py b/killua/tests/groups/tags.py index 83454f300..ab109cdaf 100644 --- a/killua/tests/groups/tags.py +++ b/killua/tests/groups/tags.py @@ -1,13 +1,14 @@ -from ..types import * -from ...utils.classes import * +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +from ..types import DiscordMember +from ..types.permissions import Permissions from ..testing import Testing, test from ...cogs.tags import Tags, Tag from ...utils.classes.guild import Guild as KilluaGuild from ...utils.test_db import TestingDatabase -from ..types.permissions import Permissions - -from datetime import datetime -from unittest.mock import patch from ..harnesses import ( assert_embed_title, diff --git a/killua/tests/groups/todo.py b/killua/tests/groups/todo.py index a1a68fb54..73bf1ec5c 100644 --- a/killua/tests/groups/todo.py +++ b/killua/tests/groups/todo.py @@ -1,18 +1,19 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.todo import TodoSystem -from ...static.constants import DB, editing -from ...utils.classes.todo import TodoList, Todo +from __future__ import annotations from datetime import datetime from unittest.mock import AsyncMock, patch -from ..harnesses import embed_footer_page, patch_user_confirm_dm, press_paginator_button from ..types import DiscordMember +from ...utils.classes import User +from ..testing import Testing, test +from ...cogs.todo import TodoSystem +from ...static.constants import DB, editing +from ...utils.classes.todo import TodoList + +from ..harnesses import embed_footer_page, patch_user_confirm_dm, press_paginator_button -def _clear_todo_state(): +def _clear_todo_state() -> None: """Reset TodoList caches, editing dict, and the todo DB collection.""" TodoList.cache.clear() TodoList.custom_id_cache.clear() diff --git a/killua/tests/groups/unit_boost.py b/killua/tests/groups/unit_boost.py index 552c3fc27..64a6ebdd5 100644 --- a/killua/tests/groups/unit_boost.py +++ b/killua/tests/groups/unit_boost.py @@ -11,12 +11,12 @@ from discord.ext import commands from discord.ext.commands import BadArgument -from ..testing import Testing, test, collect_test_classes +from ..testing import Testing, test, collect_test_classes, expect_raises from ..types import Bot, DiscordMember, Role from ...cogs.economy import Economy from ...cogs.api import IPCRoutes from ...cogs.image_manipulation import ImageManipulation -from ...static.constants import DB, LOOTBOXES, daily_users +from ...static.constants import DB, daily_users from ...static.enums import Booster from ...utils.checks import ( blcheck, @@ -91,11 +91,10 @@ async def parses_compound_duration(self) -> None: @test async def rejects_over_28_days(self) -> None: conv = TimeConverter() - try: + async with expect_raises(BadArgument) as exc_info: await conv.convert(self.base_context, "29d") - assert False, "expected BadArgument" - except BadArgument as exc: - assert "28 days" in str(exc).lower() + assert exc_info.value is not None + assert "28 days" in str(exc_info.value).lower() @test async def rejects_unknown_unit_via_dict(self) -> None: @@ -103,21 +102,19 @@ async def rejects_unknown_unit_via_dict(self) -> None: with patch.dict( "killua.utils.converters.time_dict", {"h": 3600}, clear=True ): - try: + async with expect_raises(BadArgument) as exc_info: await conv.convert(self.base_context, "1m") - assert False, "expected BadArgument" - except BadArgument as exc: - assert "invalid time-key" in str(exc).lower() + assert exc_info.value is not None + assert "invalid time-key" in str(exc_info.value).lower() @test async def rejects_non_numeric_value(self) -> None: conv = TimeConverter() with patch("killua.utils.converters.float", side_effect=ValueError): - try: + async with expect_raises(BadArgument) as exc_info: await conv.convert(self.base_context, "1h") - assert False, "expected BadArgument" - except BadArgument as exc: - assert "not a number" in str(exc).lower() + assert exc_info.value is not None + assert "not a number" in str(exc_info.value).lower() class ChecksUnit(_UnitBoostTests): diff --git a/killua/tests/groups/web_scraping.py b/killua/tests/groups/web_scraping.py index cdfd018df..d3d4feca0 100644 --- a/killua/tests/groups/web_scraping.py +++ b/killua/tests/groups/web_scraping.py @@ -1,11 +1,12 @@ -from ..types import * -from ...utils.classes import * -from ..testing import Testing, test -from ...cogs.web_scraping import WebScraping +from __future__ import annotations import inspect +from typing import Any from unittest.mock import AsyncMock, patch +from ..testing import Testing, test +from ...cogs.web_scraping import WebScraping + from ..harnesses import embed_footer_page, press_paginator_button diff --git a/killua/tests/harnesses/assertions.py b/killua/tests/harnesses/assertions.py index 71bc4c512..b136bf2e3 100644 --- a/killua/tests/harnesses/assertions.py +++ b/killua/tests/harnesses/assertions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Optional, Sequence, Tuple, Union +from typing import Any, Sequence from killua.utils.classes import User @@ -13,7 +13,7 @@ def last_content(ctx: Any) -> str: return ctx.result.message.content or "" -def embed_at(ctx: Any, index: int = -1): +def embed_at(ctx: Any, index: int = -1) -> Any | None: raw = ctx.result.message.embeds if ctx.result else None if raw is None: return None @@ -26,7 +26,7 @@ def embed_at(ctx: Any, index: int = -1): return None -def assert_content_contains(ctx: Any, needle: str, *, msg: Optional[str] = None) -> None: +def assert_content_contains(ctx: Any, needle: str, *, msg: str | None = None) -> None: content = last_content(ctx) assert needle in content, msg or f"expected {needle!r} in {content!r}" @@ -52,7 +52,7 @@ async def assert_inventory( *, has: Sequence[int] = (), lacks: Sequence[int] = (), - count: Optional[dict[int, int]] = None, + count: dict[int, int] | None = None, ) -> User: user = await reload_user(user_id) for cid in has: diff --git a/killua/tests/harnesses/context.py b/killua/tests/harnesses/context.py index f18ada38d..264bed039 100644 --- a/killua/tests/harnesses/context.py +++ b/killua/tests/harnesses/context.py @@ -7,7 +7,7 @@ @contextmanager -def respond_to_view(ctx: Any, callback: Callable): +def respond_to_view(ctx: Any, callback: Callable[..., Any]): prev = ctx.respond_to_view ctx.respond_to_view = callback try: diff --git a/killua/tests/harnesses/dm_view.py b/killua/tests/harnesses/dm_view.py index a895709e4..45539fa91 100644 --- a/killua/tests/harnesses/dm_view.py +++ b/killua/tests/harnesses/dm_view.py @@ -7,7 +7,7 @@ from ..types import ArgumentInteraction, Message -def _find_button(item, custom_id: str): +def _find_button(item: Any, custom_id: str) -> Any | None: if getattr(item, "custom_id", None) == custom_id: return item for child in getattr(item, "children", []) or []: diff --git a/killua/tests/harnesses/interaction.py b/killua/tests/harnesses/interaction.py index 78e1d65e6..4fc6f0056 100644 --- a/killua/tests/harnesses/interaction.py +++ b/killua/tests/harnesses/interaction.py @@ -6,9 +6,8 @@ from __future__ import annotations -import discord from discord import InteractionType -from typing import Any, Optional +from typing import Any class _MockFollowup: @@ -79,7 +78,7 @@ def __init__( self.guild_id = getattr(context.guild, "id", None) self.response = _MockInteractionResponse(self) self.followup = _MockFollowup(self) - self._response_message: Optional[Any] = None + self._response_message: Any | None = None self.interaction = None async def send(self, *args: Any, **kwargs: Any) -> Any: diff --git a/killua/tests/harnesses/member_dm.py b/killua/tests/harnesses/member_dm.py index 6bb9b0aa4..89d0cf1b0 100644 --- a/killua/tests/harnesses/member_dm.py +++ b/killua/tests/harnesses/member_dm.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from killua.cogs.games import RpsSelect from killua.utils.interactions import Select as KSelect @@ -10,7 +10,7 @@ from ..types import ArgumentInteraction, Message -def _find_rps_select(view: Any) -> Optional[Any]: +def _find_rps_select(view: Any) -> Any | None: for child in getattr(view, "children", []) or []: if isinstance(child, RpsSelect): return child diff --git a/killua/tests/harnesses/paginator.py b/killua/tests/harnesses/paginator.py index b86712a86..4222a81b1 100644 --- a/killua/tests/harnesses/paginator.py +++ b/killua/tests/harnesses/paginator.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import Any, Optional, Tuple +from typing import Any import discord @@ -11,7 +11,7 @@ from .views import find_button -def embed_footer_page(embed: discord.Embed) -> Optional[Tuple[int, int]]: +def embed_footer_page(embed: discord.Embed) -> tuple[int, int] | None: """Parse 'Page n/m' from DefaultEmbed footer. Returns (page, max) or None.""" foot = (embed.footer and embed.footer.text) or "" m = re.search(r"Page\s+(\d+)/(\d+)", foot) @@ -25,8 +25,8 @@ async def press_paginator_button( custom_id: str, *, context: Any, - message: Optional[Any] = None, - user: Optional[Any] = None, + message: Any | None = None, + user: Any | None = None, ) -> None: """Invoke a Paginator Buttons callback (custom_id: first, previous, next, last, delete).""" btn = find_button(view, custom_id=custom_id) diff --git a/killua/tests/harnesses/poll_wyr.py b/killua/tests/harnesses/poll_wyr.py index 40a85eaa7..b043ae84c 100644 --- a/killua/tests/harnesses/poll_wyr.py +++ b/killua/tests/harnesses/poll_wyr.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, List, Optional, Sequence +from typing import Any, Sequence import discord @@ -44,7 +44,7 @@ def build_poll_message( message_id: int = 99001, option_index: int = 1, option_count: int = 2, - visible_voter_ids: Optional[Sequence[int]] = None, + visible_voter_ids: Sequence[int] | None = None, option_button_suffix: str = "", close_suffix: str = "", ) -> Any: @@ -60,7 +60,7 @@ def build_poll_message( inline=False, ) - buttons: List[ListenerFakeButton] = [] + buttons: list[ListenerFakeButton] = [] for pos in range(1, option_count + 1): suffix = option_button_suffix if pos == option_index else "" buttons.append( @@ -90,7 +90,7 @@ def build_wyr_message( *, message_id: int = 99002, side: str = "b", - visible_voter_ids: Optional[Sequence[int]] = None, + visible_voter_ids: Sequence[int] | None = None, option_button_suffix: str = "", ) -> Any: visible_voter_ids = list(visible_voter_ids or []) diff --git a/killua/tests/harnesses/spell_use.py b/killua/tests/harnesses/spell_use.py index e9191aee0..dcb5be412 100644 --- a/killua/tests/harnesses/spell_use.py +++ b/killua/tests/harnesses/spell_use.py @@ -2,9 +2,8 @@ from __future__ import annotations -import math from contextlib import contextmanager -from typing import Any, Optional, Sequence, Tuple, Union +from typing import Any, Sequence from unittest.mock import patch from killua.utils.interactions import Select as KSelect @@ -19,27 +18,27 @@ assert_inventory, embed_at, last_content, - reload_user, ) SPELL_IDS_WITH_EXEC = [ + # Spell card IDs that have a dedicated ``UseSpell*`` integration test class. 1001, 1002, 1007, 1008, 1010, 1011, 1015, 1018, 1020, 1021, 1024, 1026, 1028, 1029, 1031, 1032, 1035, 1036, 1038, ] DEFENSE_SPELL_IDS = list(DEF_SPELLS) + list(VIEW_DEF_SPELLS) DEFAULT_ATTACK_SPELL = 1021 STEAL_TARGET_CARD = 50 -MET_ERROR_FRAGMENT = "haven't met this user yet" +MET_ERROR_FRAGMENT = "haven't met this user yet" # ``cards use`` on unmet target DEFENSE_SUCCESS_FRAGMENT = "successfully defended" -ATTACK_TIMEOUT_FRAGMENT = "attack goes through" +ATTACK_TIMEOUT_FRAGMENT = "attack goes through" # defense window expired async def setup_author_spell( author_id: int, spell_id: int, *, - extra_fs: Optional[Sequence[int]] = None, - met_ids: Optional[Sequence[int]] = None, + extra_fs: Sequence[int] | None = None, + met_ids: Sequence[int] | None = None, ) -> User: user = await User.new(author_id) await user.nuke_cards("all") @@ -54,12 +53,12 @@ async def setup_author_spell( async def setup_target_user( target_id: int, *, - fs_cards: Optional[Sequence] = None, - rs_cards: Optional[Sequence] = None, - defense_ids: Optional[Sequence[int]] = None, - effects: Optional[dict] = None, + fs_cards: Sequence[int | tuple] | None = None, + rs_cards: Sequence[int | tuple] | None = None, + defense_ids: Sequence[int] | None = None, + effects: dict | None = None, met_attacker: bool = True, - attacker_id: Optional[int] = None, + attacker_id: int | None = None, ) -> User: user = await User.new(target_id) await user.nuke_cards("all") @@ -102,7 +101,7 @@ def _is_member_target(target: Any) -> bool: async def invoke_use( testing: Any, - card_id: Union[int, str], + card_id: int | str, *, target: Any = None, args: Any = None, @@ -132,7 +131,7 @@ def make_target_member(testing: Any, target_id: int) -> DiscordMember: return member -def target_member(testing: Any, offset: int) -> Tuple[DiscordMember, int]: +def target_member(testing: Any, offset: int) -> tuple[DiscordMember, int]: target_id = testing.base_author.id + offset return make_target_member(testing, target_id), target_id @@ -142,8 +141,8 @@ async def setup_met_view_spell( spell_id: int, offset: int, *, - fs_cards: Optional[Sequence] = None, -) -> Tuple[DiscordMember, int]: + fs_cards: Sequence[int | tuple] | None = None, +) -> tuple[DiscordMember, int]: member, target_id = target_member(testing, offset) await setup_author_spell( testing.base_author.id, spell_id, met_ids=[target_id] @@ -236,8 +235,8 @@ async def run_attack_against_defender( stolen_card: int = STEAL_TARGET_CARD, use_defense: bool = True, attacker_in_met: bool = True, - patch_attacker_range: Optional[str] = None, -) -> Tuple[DiscordMember, int]: + patch_attacker_range: str | None = None, +) -> tuple[DiscordMember, int]: target_id = testing.base_author.id + 50_000 target_member_obj = make_target_member(testing, target_id) await setup_author_spell( diff --git a/killua/tests/harnesses/views.py b/killua/tests/harnesses/views.py index 92e45157b..bebfaac41 100644 --- a/killua/tests/harnesses/views.py +++ b/killua/tests/harnesses/views.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Iterator, Optional +from typing import Any, Iterator import discord @@ -24,9 +24,9 @@ def iter_view_items(view: Any) -> Iterator[Any]: def find_button( view: Any, *, - custom_id: Optional[str] = None, - label: Optional[str] = None, -) -> Optional[Any]: + custom_id: str | None = None, + label: str | None = None, +) -> Any | None: for item in iter_view_items(view): if not isinstance(item, discord.ui.Button): continue @@ -38,7 +38,7 @@ def find_button( return None -def find_select(view: Any, *, custom_id: Optional[str] = None) -> Optional[Any]: +def find_select(view: Any, *, custom_id: str | None = None) -> Any | None: for item in iter_view_items(view): if isinstance(item, discord.ui.Select) and ( custom_id is None or getattr(item, "custom_id", None) == custom_id diff --git a/killua/tests/testing.py b/killua/tests/testing.py index 66d07073b..5cbebd25e 100644 --- a/killua/tests/testing.py +++ b/killua/tests/testing.py @@ -5,7 +5,8 @@ import sys, traceback import logging -from typing import TYPE_CHECKING, List, Coroutine, Optional +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Coroutine from . import config @@ -13,14 +14,14 @@ from .types import Context, TestResult -def _test_class_command_name(cls) -> str: +def _test_class_command_name(cls: type) -> str: """Discord command name for this test class (defaults to class name).""" return getattr(cls, "command_name", None) or cls.__name__.lower() -def collect_test_classes(group_cls: type) -> list: +def collect_test_classes(group_cls: type) -> list[type]: """Return leaf test classes registered under a group (depth-first subclass walk).""" - found: list = [] + found: list[type] = [] def walk(base: type) -> None: for cls in base.__subclasses__(): @@ -33,6 +34,27 @@ def walk(base: type) -> None: return found +class _ExcHolder: + """Populated by :func:`expect_raises` when the expected exception is caught.""" + + value: BaseException | None = None + + +@asynccontextmanager +async def expect_raises(exc_type: type[BaseException]): + """Assert that the wrapped block raises *exc_type* (async-compatible). + + Yields an :class:`_ExcHolder` whose ``value`` is the caught exception instance. + """ + holder = _ExcHolder() + try: + yield holder + except exc_type as exc: + holder.value = exc + else: + raise AssertionError(f"expected {exc_type.__name__} to be raised") + + class Testing: """Modifies several discord classes to be suitable in a testing environment""" @@ -92,7 +114,7 @@ def _should_bind_command(cls) -> bool: return False @property - def all_tests(self) -> List[Testing]: + def all_tests(self) -> list[type]: """Automatically checks what functions are test based on their name and the overlap with the Cog method names""" cog_methods = [] for cmd in [(command.name, command) for command in self.cog.get_commands()]: @@ -101,7 +123,7 @@ def all_tests(self) -> List[Testing]: for child in cmd[1].walk_commands(): cog_methods.append((child.name, child)) - command_classes: List[Testing] = [] + command_classes: list[type] = [] for cls in self.__class__.__subclasses__(): # print(cls) @@ -127,7 +149,7 @@ def command(self) -> Coroutine: if command.name.lower() == want.lower(): return command - async def run_tests(self, only_command: Optional[str] = None) -> TestResult: + async def run_tests(self, only_command: str | None = None) -> TestResult: """The function that returns the test result for this group""" for test in self.all_tests: command = test() @@ -152,7 +174,7 @@ async def test_command(self) -> None: await method(self) @classmethod - async def press_confirm(cls, context: Context): + async def press_confirm(cls, context: Context) -> None: """Presses the confirm button of a ConfirmView or ConfirmButton""" from .harnesses.views import find_button from .types import ArgumentInteraction @@ -162,7 +184,7 @@ async def press_confirm(cls, context: Context): await button.callback(ArgumentInteraction(context)) @classmethod - async def press_cancel(cls, context: Context): + async def press_cancel(cls, context: Context) -> None: """Presses the cancel button of a ConfirmView or ConfirmButton.""" from .harnesses.views import find_button from .types import ArgumentInteraction diff --git a/killua/tests/types/bot.py b/killua/tests/types/bot.py index 07743de33..17e7bd006 100644 --- a/killua/tests/types/bot.py +++ b/killua/tests/types/bot.py @@ -12,7 +12,8 @@ from .channel import TestingTextChannel from .user import TestingUser -from typing import Any, Optional, Callable, Tuple +from typing import Any, Callable + from asyncio import get_event_loop, TimeoutError, sleep @@ -45,8 +46,8 @@ def wait_for( event: str, /, *, - check: Optional[Callable[..., bool]] = None, - timeout: Optional[float] = None, + check: Callable[..., bool] | None = None, + timeout: float | None = None, ) -> Any: if self.fail_timeout: raise TimeoutError @@ -96,7 +97,7 @@ async def send_message(self, messageable: Messageable, *args, **kwargs) -> Messa async def find_dominant_color(self, url: str) -> int: return 0x3E4A78 - def sha256_for_api(self, endpoint: str, expires_in_seconds: int) -> Tuple[str, str]: + def sha256_for_api(self, endpoint: str, expires_in_seconds: int) -> tuple[str, str]: return ("fake_token", "9999999999") def api_url(self, *, to_fetch=False, is_for_cards=False): @@ -110,7 +111,7 @@ async def make_embed_from_api( no_token: bool = False, thumbnail: bool = False, force_fetch: bool = False, - ) -> Tuple[discord.Embed, Optional[discord.File]]: + ) -> tuple[discord.Embed, discord.File | None]: if thumbnail: embed.set_thumbnail(url=image_url) else: diff --git a/killua/tests/types/channel.py b/killua/tests/types/channel.py index 8f0b0dcb7..34a8521c2 100644 --- a/killua/tests/types/channel.py +++ b/killua/tests/types/channel.py @@ -1,7 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Union -from functools import partial +from typing import TYPE_CHECKING from .message import TestingMessage as Message from .guild import TestingGuild as Guild @@ -14,27 +13,33 @@ from .utils import get_random_discord_id +from functools import partial + class TestingTextChannel: """A class imulating a discord text channel""" __class__ = TextChannel - def __init__(self, guild: Guild, permissions: List[dict] = [], **kwargs): + def __init__( + self, guild: Guild, permissions: list[dict] | None = None, **kwargs + ): + if permissions is None: + permissions = [] self.guild: Guild = guild self.name: str = kwargs.pop("name", "test") self.id: int = kwargs.pop("id", get_random_discord_id()) self.guild_i: int = kwargs.pop("guild_id", get_random_discord_id()) self.position: int = kwargs.pop("position", 1) - self.permission_overwrites: List[PermissionOverwrite] = ( + self.permission_overwrites: list[PermissionOverwrite] = ( self.__handle_permissions(permissions) ) self.nsfw: bool = kwargs.pop("nsfw", False) - self.parent: Union[CategoryChannel, None] = kwargs.pop("parent", None) + self.parent: CategoryChannel | None = kwargs.pop("parent", None) self.type: int = kwargs.pop("type", 0) self._has_permission: int = kwargs.pop("has_permission", True) - self.history_return: List[Message] = [] + self.history_return: list[Message] = [] def __handle_permissions(self, permissions) -> None: """Handles permissions""" @@ -49,7 +54,7 @@ def __handle_permissions(self, permissions) -> None: async def history( self, limit: int = None, before: Message = None, after: Message = None - ) -> List[Message]: + ) -> list[Message]: """Gets the history of the channel""" for message in self.history_return[:limit]: yield message @@ -60,7 +65,7 @@ async def send(self, content: str, *args, **kwargs) -> None: author=self.me, channel=self.channel, content=content, *args, **kwargs ) self.result = ResultData(message=message) - self.ctx.current_view: Union[ui.View, None] = kwargs.pop("view", None) + self.ctx.current_view: ui.View | None = kwargs.pop("view", None) if self.ctx.current_view: if self.ctx.timeout_view: diff --git a/killua/tests/types/context.py b/killua/tests/types/context.py index d8a7776b0..4303dd448 100644 --- a/killua/tests/types/context.py +++ b/killua/tests/types/context.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from discord.ext.commands import Context, Command from discord.ui import View @@ -7,7 +9,6 @@ from .channel import TestingTextChannel as TextChannel from .member import TestingMember as Member -from typing import Union from functools import partial @@ -17,18 +18,18 @@ class TestingContext: __class__ = Context def __init__(self, **kwargs): - self.result: Union[ResultData, None] = None + self.result: ResultData | None = None self.me: User = User() self.message: Message = kwargs.pop("message") self.bot: User = kwargs.pop("bot") self.channel: TextChannel = self.message.channel self.author: Member = self.message.author - self.command: Union[Command, None] = None + self.command: Command | None = None self.invoked_subcommand = kwargs.pop("invoked_subcommand", None) self.interaction = None self.guild = self.message.channel.guild - self.current_view: Union[View, None] = None + self.current_view: View | None = None self.message.channel.ctx: TestingContext = self self.message.ctx: TestingContext = self self.timeout_view: bool = False @@ -39,7 +40,7 @@ async def reply(self, content: str, *args, **kwargs) -> Message: author=self.me, channel=self.channel, content=content, *args, **kwargs ) self.result = ResultData(message=message) - self.current_view: Union[View, None] = kwargs.pop("view", None) + self.current_view: View | None = kwargs.pop("view", None) if self.current_view: if self.timeout_view: @@ -56,7 +57,7 @@ async def send(self, content: str = None, *args, **kwargs) -> Message: author=self.me, channel=self.channel, content=content, *args, **kwargs ) self.result = ResultData(message=message) - self.current_view: Union[View, None] = kwargs.pop("view", None) + self.current_view: View | None = kwargs.pop("view", None) if self.current_view: if self.timeout_view: diff --git a/killua/tests/types/guild.py b/killua/tests/types/guild.py index cb400bc94..5bf715581 100644 --- a/killua/tests/types/guild.py +++ b/killua/tests/types/guild.py @@ -5,7 +5,7 @@ from .utils import get_random_discord_id, random_name from .asset import Asset as TestingAsset -from typing import Union, List +from typing import Any class TestingGuild: @@ -26,29 +26,29 @@ def __init__(self, **kwargs): "default_message_notifications", 0 ) self.explicit_content_filter: int = kwargs.pop("explicit_content_filter", 0) - self.roles: list = kwargs.pop("roles", []) + self.roles: list[Any] = kwargs.pop("roles", []) self.mfa_level: int = kwargs.pop("mfa_level", 0) self.nsfw_level: int = kwargs.pop("nsfw_level", 0) - self.application_id: Union[int, None] = kwargs.pop("application_id", None) - self.system_channel_id: Union[int, None] = kwargs.pop("system_channel_id", None) + self.application_id: int | None = kwargs.pop("application_id", None) + self.system_channel_id: int | None = kwargs.pop("system_channel_id", None) self.system_channel_flags: int = kwargs.pop("system_channel_flags", 0) - self.rules_channel_id: Union[int, None] = kwargs.pop("rules_channel_id", None) - self.vanity_url_code: Union[int, None] = kwargs.pop("vanity_url_code", None) - self.banner: Union[Asset, None] = kwargs.pop("banner", None) + self.rules_channel_id: int | None = kwargs.pop("rules_channel_id", None) + self.vanity_url_code: int | None = kwargs.pop("vanity_url_code", None) + self.banner: Asset | None = kwargs.pop("banner", None) self.premium_tier: int = kwargs.pop("premium_tier", 0) self.preferred_locale: str = kwargs.pop("preferred_locale", "us") - self.public_updates_channel_id: Union[int, None] = kwargs.pop( + self.public_updates_channel_id: int | None = kwargs.pop( "public_updates_channel_id", None ) - self.stickers: list = kwargs.pop("stickers", []) - self.stage_instances: list = kwargs.pop("stage_instances", []) - self.guild_scheduled_events: list = kwargs.pop("guild_sceduled_events", []) + self.stickers: list[Any] = kwargs.pop("stickers", []) + self.stage_instances: list[Any] = kwargs.pop("stage_instances", []) + self.guild_scheduled_events: list[Any] = kwargs.pop("guild_sceduled_events", []) self.icon = kwargs.pop("icon", TestingAsset()) self.member_count: int = kwargs.pop("member_count", 10) - self.members: List = kwargs.pop("members", []) + self.members: list[Any] = kwargs.pop("members", []) self.chunked: bool = True # Ban list entries: objects with .user (TestingUser-like) for unban by name - self._ban_list: List = kwargs.pop("_ban_list", []) + self._ban_list: list[Any] = kwargs.pop("_ban_list", []) async def ban(self, user, **kwargs) -> None: """No-op; tests may assert call via patch.""" diff --git a/killua/tests/types/interaction.py b/killua/tests/types/interaction.py index 5e840b3bd..76b4c553c 100644 --- a/killua/tests/types/interaction.py +++ b/killua/tests/types/interaction.py @@ -3,7 +3,7 @@ from discord import Interaction from discord.ext.commands import Context -from typing import Literal, Optional, Any +from typing import Literal, Any from .utils import get_random_discord_id, random_name diff --git a/killua/tests/types/member.py b/killua/tests/types/member.py index dbd69f260..1920372b8 100644 --- a/killua/tests/types/member.py +++ b/killua/tests/types/member.py @@ -3,7 +3,6 @@ from discord.types.snowflake import Snowflake from random import randint -from typing import List, Union from datetime import datetime from .utils import get_random_discord_id, random_date @@ -19,7 +18,7 @@ class TestingMember(User): def __init__(self, **kwargs): super().__init__(**kwargs) - self.roles: list = kwargs.pop("roles", self.__random_roles()) + self.roles: list[int] = kwargs.pop("roles", self.__random_roles()) self.joined_at: str = kwargs.pop("joined_at", str(random_date())) self.deaf: bool = kwargs.pop("deaf", False) self.muted: bool = kwargs.pop("muted", False) @@ -27,7 +26,7 @@ def __init__(self, **kwargs): self.communication_disabled_until: str = kwargs.pop( "communication_disabled_until", "" ) - self.premium_since: Union[datetime, None] = kwargs.pop("premium_since", None) + self.premium_since: datetime | None = kwargs.pop("premium_since", None) self.top_role = kwargs.pop("top_role", TestingRole(position=1)) self.guild_permissions = kwargs.pop("guild_permissions", Permissions(administrator=True)) self._timed_out = kwargs.pop("timed_out", False) @@ -44,6 +43,6 @@ async def ban(self, **kwargs): ... async def kick(self, **kwargs): ... async def timeout(self, *args, **kwargs): ... - def __random_roles(self) -> List[Snowflake]: + def __random_roles(self) -> list[Snowflake]: """Creates a random list of roles a user has""" return [get_random_discord_id() for _ in range(randint(0, 10))] diff --git a/killua/tests/types/permissions.py b/killua/tests/types/permissions.py index 14535a67a..fbc5498ce 100644 --- a/killua/tests/types/permissions.py +++ b/killua/tests/types/permissions.py @@ -1,5 +1,3 @@ -from typing import List - class Permission: create_instant_invite = 1 << 0 @@ -54,11 +52,11 @@ def __init__(self): self.allow: int = 0 self.deny: int = 0 - def allow_perms(self, perms: List[Permission]) -> None: + def allow_perms(self, perms: list[Permission]) -> None: for val in perms: self.allow |= int(val) - def deny_perms(self, perms: List[Permission]) -> None: + def deny_perms(self, perms: list[Permission]) -> None: for val in perms: self.deny |= int(val) diff --git a/killua/tests/types/role.py b/killua/tests/types/role.py index 442c508ba..23cdcd84e 100644 --- a/killua/tests/types/role.py +++ b/killua/tests/types/role.py @@ -3,8 +3,6 @@ from .utils import random_name from .permissions import Permissions -from typing import Optional - class TestingRole: @@ -16,8 +14,8 @@ def __init__(self, **kwargs): self.position: int = kwargs.pop("position", 0) self._colour: int = kwargs.pop("colour", 0) self.hoist: bool = kwargs.pop("hoist", False) - self._icon: Optional[str] = kwargs.pop("icon", None) - self.unicorn_emoji: Optional[str] = kwargs.pop("unicorn_emoji", None) + self._icon: str | None = kwargs.pop("icon", None) + self.unicorn_emoji: str | None = kwargs.pop("unicorn_emoji", None) self.managed: bool = kwargs.pop("managed", False) self.mentionable: bool = kwargs.pop("mentionable", False) self.tags = kwargs.pop("tags", None) diff --git a/killua/tests/types/testing_results.py b/killua/tests/types/testing_results.py index 0c814e548..deb8947f3 100644 --- a/killua/tests/types/testing_results.py +++ b/killua/tests/types/testing_results.py @@ -3,7 +3,7 @@ from discord.ext.commands import Command from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any class Result(Enum): @@ -33,14 +33,14 @@ def __init__(self): self.failed = [] self.errored = [] # One entry per @test run, in order, for this command class instance only. - self.records: List[Dict[str, Any]] = [] + self.records: list[dict[str, Any]] = [] # Populated on the per-cog Testing aggregate: command name -> list of record dicts. - self.by_command: Dict[str, List[Dict[str, Any]]] = {} + self.by_command: dict[str, list[dict[str, Any]]] = {} def completed_test( self, command: Command, result: Result, result_data: ResultData = None ) -> None: - err: Optional[str] = None + err: str | None = None if result_data is not None and result_data.error is not None: err = str(result_data.error) self.records.append( diff --git a/killua/utils/checks.py b/killua/utils/checks.py index ee74deff0..40d71a2bf 100644 --- a/killua/utils/checks.py +++ b/killua/utils/checks.py @@ -1,6 +1,6 @@ import discord from discord.ext import commands -from typing import Union, Type +from typing import Type from datetime import datetime, timedelta from typing import cast @@ -126,7 +126,7 @@ def add_daily_user(userid: int): daily_users.users.append(userid) async def add_usage( - command: Union[commands.Command, Type[commands.Command]], + command: commands.Command | Type[commands.Command], ) -> None: """Adds one to the usage count of a command""" if isinstance(command, commands.HybridGroup) or isinstance( diff --git a/killua/utils/classes/book.py b/killua/utils/classes/book.py index a8c58f396..184bd2eec 100644 --- a/killua/utils/classes/book.py +++ b/killua/utils/classes/book.py @@ -1,11 +1,12 @@ from __future__ import annotations +from typing import Any + import discord from PIL import Image, ImageDraw, ImageFont from io import BytesIO from datetime import datetime from pathlib import Path -from typing import Tuple, Union, Optional from killua.utils.classes.user import User from killua.utils.classes.card import Card @@ -24,18 +25,18 @@ def __init__( self.session = client.session self.base_url = client.api_url(to_fetch=True) self.client = client - self._book_token_cache: Optional[Tuple[str, str]] = None + self._book_token_cache: tuple[str, str] | None = None self.scalar = 2 @property - def book_token_cache(self) -> Tuple[str, str]: + def book_token_cache(self) -> tuple[str, str]: # No token is permanent, so it may need to be refreshed if the token is older than 24 hours if self._book_token_cache is None or datetime.fromtimestamp(float(self._book_token_cache[1])) < datetime.now(): self._book_token_cache = self.client.sha256_for_api("book", 60 * 60 * 24) return self._book_token_cache async def create_image( - self, data: list, restricted_slots: bool, page: int + self, data: list[list[Any]], restricted_slots: bool, page: int ) -> Image.Image: """Creates the book image of the current page and returns it""" background = await self._get_background(0 if len(data) == 10 else 1) @@ -45,7 +46,7 @@ async def create_image( background = self._set_page(background, page) return background - def _get_from_cache(self, types: int) -> Union[Image.Image, None]: + def _get_from_cache(self, types: int) -> Image.Image | None: """Gets background from the cache if it exists, otherwise returns None""" if types == 0: if "first_page" in self.background_cache: @@ -109,9 +110,9 @@ def _get_font(self, size: int) -> ImageFont.FreeTypeFont: ) return font - async def _cards(self, image: Image.Image, data: list, option: int) -> Image.Image: + async def _cards(self, image: Image.Image, data: list[list[Any]], option: int) -> Image.Image: """Puts the cards on the background if there are any""" - card_pos: list = [ + card_pos: list[list[tuple[int, int]]] = [ [ (112, 143), (318, 15), @@ -156,10 +157,10 @@ async def _cards(self, image: Image.Image, data: list, option: int) -> Image.Ima image.paste(card, (card_pos[option][n]), card) return image - def _numbers(self, image: Image.Image, data: list, page: int) -> Image.Image: + def _numbers(self, image: Image.Image, data: list[list[Any]], page: int) -> Image.Image: """Puts the numbers on the restricted slots in the book""" page -= 1 - numbers_pos: list = [ + numbers_pos: list[list[tuple[int, int]]] = [ [ (130, 188), (338, 60), @@ -308,7 +309,7 @@ async def _get_book( page: int, client: Bot, just_fs_cards: bool = False, - ) -> Tuple[discord.Embed, discord.File]: + ) -> tuple[discord.Embed, discord.File]: """Gets a formatted embed containing the book for the user""" rs_cards = [] fs_cards = [] @@ -381,5 +382,5 @@ async def create( user: discord.Member, page: int, just_fs_cards: bool = False, - ) -> Tuple[discord.Embed, discord.File]: + ) -> tuple[discord.Embed, discord.File]: return await self._get_book(user, page, self.client, just_fs_cards) diff --git a/killua/utils/classes/card.py b/killua/utils/classes/card.py index ca520e561..8f88da6b5 100644 --- a/killua/utils/classes/card.py +++ b/killua/utils/classes/card.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import List, ClassVar, Dict, Tuple, Union, Tuple, TYPE_CHECKING, Type - -from killua.static.constants import DB -from killua.bot import BaseBot +from typing import Any, ClassVar, TYPE_CHECKING, Type, Callable import discord from io import BytesIO from discord.ext import commands -from typing import List, Tuple, Optional, Callable from killua.static.constants import ( ALLOWED_AMOUNT_MULTIPLE, @@ -41,7 +37,7 @@ class CardNotFound(Exception): class Card: """A class preventing a circular import by providing the bare minimum of methods and properties. Only used in this module""" - raw: ClassVar[List[Dict[str, Union[str, int, bool]]]] = [] # Raw data from API + raw: ClassVar[list[dict[str, str | int | bool]]] = [] # Raw data from API id: int name: str @@ -52,12 +48,12 @@ class Card: limit: int available: bool type: str = "normal" - range: Optional[str] = None - ctx: Optional[commands.Context] = None - _cls: Optional[List[str]] = None + range: str | None = None + ctx: commands.Context | None = None + _cls: list[str] | None = None - cache: ClassVar[Dict[int, Card]] = {} # Cached objects - cached_raw: ClassVar[List[Tuple[str, int]]] = [] # String to int ID mapping + cache: ClassVar[dict[int, Card]] = {} # Cached objects + cached_raw: ClassVar[list[tuple[str, int]]] = [] # String to int ID mapping @classmethod def _should_ignore(cls, cached: Type[Card]) -> bool: @@ -82,7 +78,7 @@ def __new__(cls, name_or_id: int, *args, **kwargs): return super().__new__(cls) @classmethod - def _find_card(cls, name_or_id: Union[int, str]) -> Union[int, None]: + def _find_card(cls, name_or_id: int | str) -> int | None: # This could be solved much easier but this allows the user to # have case insensitivity when looking for a card @@ -101,7 +97,7 @@ def _find_card(cls, name_or_id: Union[int, str]) -> Union[int, None]: if c[1] == int(name_or_id): return c[1] - def __init__(self, name_or_id: Union[str, int], ctx: Optional[commands.Context] = None): + def __init__(self, name_or_id: str | int, ctx: commands.Context | None = None): cards_id = self._find_card(name_or_id) if cards_id in self.cache and not self._should_ignore(self.cache[cards_id]): @@ -129,14 +125,14 @@ def __init__(self, name_or_id: Union[str, int], ctx: Optional[commands.Context] self.cache[cards_id] = self @classmethod - def find(cls, conditions: Callable[dict, bool]) -> List[Card]: # type: ignore + def find(cls, conditions: Callable[dict, bool]) -> list[Card]: # type: ignore """ Finds all cards that match the given conditions, replacing mongo's find method. Already parses the cards to Card classes in the return value """ return [cls(c["id"]) for c in cls.raw if conditions(c)] - async def owners(self) -> List[int]: + async def owners(self) -> list[int]: return [ entry["id"] async for entry in DB.teams.find( @@ -171,7 +167,7 @@ async def _wait_for_defense( if len(effects) == 0: return - effect_instances: List["Card"] = [Card(c) for c in effects] + effect_instances: list["Card"] = [Card(c) for c in effects] view = View(other.id, timeout=20) view.add_item( Select( @@ -271,7 +267,7 @@ def _permission_check(self, ctx: commands.Context, member: discord.Member) -> No def _has_cards_check( self, - cards: List[list], + cards: list[list], card_type: str = "", is_self: bool = False, uses_up: bool = False, @@ -298,7 +294,7 @@ def _has_met_check( f"You haven't met this user yet! Use `{prefix}meet <@someone>` if they send a message in a channel to be able to use this card on them" ) - def _has_other_card_check(self, cards: List[list]) -> None: + def _has_other_card_check(self, cards: list[list]) -> None: if len(cards) < 2: raise CheckFailure(f"You don't have any cards other than card {self.name}!") @@ -325,7 +321,7 @@ def _has_effect_check(self, user: "User", effect: str) -> None: async def _get_analysis_embed( self, card_id: int, client: BaseBot - ) -> Tuple[discord.Embed, Optional[discord.File]]: + ) -> tuple[discord.Embed, discord.File | None]: card = Card(card_id) fields = [ {"name": "Name", "value": card.name + " " + card.emoji, "inline": True}, @@ -366,7 +362,7 @@ async def _get_analysis_embed( async def _get_list_embed( self, card_id: int, client: BaseBot - ) -> Tuple[discord.Embed, Optional[discord.File]]: + ) -> tuple[discord.Embed, discord.File | None]: card = Card(card_id) real_owners = [] diff --git a/killua/utils/classes/guild.py b/killua/utils/classes/guild.py index 15aa9aa35..5340da463 100644 --- a/killua/utils/classes/guild.py +++ b/killua/utils/classes/guild.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import List, Dict, Any, ClassVar, Optional +from typing import Any, ClassVar from dataclasses import dataclass, field from inspect import signature from datetime import datetime @@ -15,14 +15,14 @@ class Guild: id: int prefix: str approximate_member_count: int = 0 - badges: List[str] = field(default_factory=list) + badges: list[str] = field(default_factory=list) commands: dict = field( default_factory=dict ) # The logic behind this is not used and needs to be rewritten polls: dict = field(default_factory=dict) - tags: List[dict] = field(default_factory=list) - added_on: Optional[datetime] = None - cache: ClassVar[Dict[int, Guild]] = {} + tags: list[dict] = field(default_factory=list) + added_on: datetime | None = None + cache: ClassVar[dict[int, Guild]] = {} @classmethod def from_dict(cls, raw: dict): @@ -31,7 +31,7 @@ def from_dict(cls, raw: dict): ) @classmethod - async def update_member_count(cls, guild_id: int, old_member_count: Optional[int], member_count: int) -> Optional[int]: + async def update_member_count(cls, guild_id: int, old_member_count: int | None, member_count: int) -> int | None: """If saved member count is inaccurate by > 5%, update it""" old_member_count = old_member_count or 0 if member_count > old_member_count * 1.05 or member_count < old_member_count * 0.95: @@ -42,7 +42,7 @@ async def update_member_count(cls, guild_id: int, old_member_count: Optional[int return None @classmethod - async def _member_count_helper(cls, guild_id: int, approximate_member_count: Optional[int], member_count: Optional[int]) -> int: + async def _member_count_helper(cls, guild_id: int, approximate_member_count: int | None, member_count: int | None) -> int: """Helper function to get the member count""" if member_count: return await cls.update_member_count( @@ -51,14 +51,14 @@ async def _member_count_helper(cls, guild_id: int, approximate_member_count: Opt return approximate_member_count or 0 @classmethod - async def new(cls, guild_id: int, member_count: Optional[int] = None) -> Guild: + async def new(cls, guild_id: int, member_count: int | None = None) -> Guild: if guild_id in cls.cache: cls.cache[guild_id].approximate_member_count = await cls._member_count_helper( guild_id, cls.cache[guild_id].approximate_member_count, member_count ) return cls.cache[guild_id] - raw: Optional[dict] = await DB.guilds.find_one({"id": guild_id}) # type: ignore + raw: dict | None = await DB.guilds.find_one({"id": guild_id}) # type: ignore if raw is None: await cls.add_default(guild_id, member_count) raw: dict = await DB.guilds.find_one({"id": guild_id}) # type: ignore @@ -77,14 +77,14 @@ def is_premium(self) -> bool: return ("partner" in self.badges) or ("premium" in self.badges) @classmethod - async def add_default(cls, guild_id: int, member_count: Optional[int]) -> None: + async def add_default(cls, guild_id: int, member_count: int | None) -> None: """Adds a guild to the database""" await DB.guilds.insert_one( {"id": guild_id, "points": 0, "items": "", "badges": [], "prefix": "k!", "approximate_member_count": member_count or 0, "added_on": datetime.now()} ) @classmethod - async def bulk_remove_premium(cls, guild_ids: List[int]) -> None: + async def bulk_remove_premium(cls, guild_ids: list[int]) -> None: """Removes premium from all guilds specified, if possible""" for guild in guild_ids: try: @@ -99,7 +99,7 @@ async def bulk_remove_premium(cls, guild_ids: List[int]) -> None: ) @classmethod - async def get_premium_subset(cls, guild_ids: List[int]) -> List[int]: + async def get_premium_subset(cls, guild_ids: list[int]) -> list[int]: """Returns a list of guild ids that have premium from the given list""" cursor = DB.guilds.find( {"id": {"$in": guild_ids}, "badges": {"$in": ["premium", "partner"]}}, diff --git a/killua/utils/classes/lootbox.py b/killua/utils/classes/lootbox.py index 8842e9430..81c769e37 100644 --- a/killua/utils/classes/lootbox.py +++ b/killua/utils/classes/lootbox.py @@ -3,7 +3,7 @@ import discord from discord.ext import commands from random import sample, randint, choices, choice -from typing import List, Dict, Union, cast, Tuple +from typing import cast from killua.static.constants import DB from killua.static.enums import Booster @@ -16,7 +16,7 @@ class _BoosterSelect(discord.ui.Select): """A class letting users pick an option when trying to use a booster""" - def __init__(self, used: List[int], inventory: Dict[str, int], **kwargs): + def __init__(self, used: list[int], inventory: dict[str, int], **kwargs): super().__init__( min_values=1, max_values=1, @@ -59,7 +59,7 @@ async def callback(self, interaction: discord.Interaction) -> None: class _OptionView(View): - def __init__(self, used: List[int], **kwargs): + def __init__(self, used: list[int], **kwargs): self.used = used super().__init__(**kwargs) @@ -133,7 +133,7 @@ class _LootBoxButton(discord.ui.Button): def __init__( self, index: int, - rewards: List[Union[Card, Booster, int, None]] = None, + rewards: list[Card | Booster | int | None] = None, **kwargs, ): super().__init__(**kwargs) @@ -146,12 +146,12 @@ def __init__( self.bomb = "<:bomb:1091111339226824776>" @property - def rewards(self) -> List[Union[Card, Booster, int, None]]: + def rewards(self) -> list[Card | Booster | int | None]: """Returns the rewards""" return self._rewards or self.view.rewards @property - def reward(self) -> Union[Card, Booster, int, None]: + def reward(self) -> Card | Booster | int | None: """Returns the reward of this button""" if self.index == 24: return None @@ -291,7 +291,7 @@ def _use_booster(self, booster: int) -> None: if booster == 1: # Treasure map. Find most valuable reward and highlight it by looking in self.rewards # and self.view.claimed - def _monetary_value(x: Union[Card, Booster, int, None]) -> int: + def _monetary_value(x: Card | Booster | int | None) -> int: """Returns the monetary value of a reward""" if isinstance(x, Card): return PRICES[x.rank] @@ -345,7 +345,7 @@ def _monetary_value(x: Union[Card, Booster, int, None]) -> int: async def _options_button( self, interaction: discord.Interaction - ) -> Union[None, discord.Message]: + ) -> None | discord.Message: """Handles the "options" button""" # Create a new view with options "save" and "use booster" view = _OptionView(self.view.used, user_id=interaction.user.id, timeout=None) @@ -411,7 +411,7 @@ async def _handle_correct(self, interaction: discord.Interaction) -> None: async def callback( self, interaction: discord.Interaction - ) -> Union[None, discord.Message]: + ) -> None | discord.Message: """The callback of the button which calls the right method depending on the reward and index""" if self.index == 24: return await self._options_button(interaction) @@ -426,11 +426,11 @@ async def callback( class LootBox: """A class which contains infos about a lootbox and can open one""" - def __init__(self, ctx: commands.Context, rewards: List[Union[None, Card, int]]): + def __init__(self, ctx: commands.Context, rewards: list[None | Card | int]): self.ctx = ctx self.rewards = rewards - def _assign_until_unique(self, taken: List[int]) -> int: + def _assign_until_unique(self, taken: list[int]) -> int: if taken[(res := randint(0, 23))]: return self._assign_until_unique(taken) return res @@ -456,7 +456,7 @@ def _create_view(self) -> discord.ui.View: return view @staticmethod - def get_lootbox_from_sku(sku: discord.SKU) -> Tuple[int, int]: + def get_lootbox_from_sku(sku: discord.SKU) -> tuple[int, int]: """Gets a lootbox from a sku""" lootbox_amount = { 7: 3, @@ -475,7 +475,7 @@ def get_random_lootbox() -> int: )[0] @classmethod - async def generate_rewards(cls, box: int) -> List[Union[Card, int]]: + async def generate_rewards(cls, box: int) -> list[Card | int]: """Generates a list of rewards that can be used to pass to this class""" data = LOOTBOXES[box] rew = [] diff --git a/killua/utils/classes/todo.py b/killua/utils/classes/todo.py index 1f504fa00..801d5d656 100644 --- a/killua/utils/classes/todo.py +++ b/killua/utils/classes/todo.py @@ -1,7 +1,7 @@ from __future__ import annotations from random import randint -from typing import Any, ClassVar, Dict, List, Optional, Union +from typing import Any, ClassVar from dataclasses import dataclass from datetime import datetime @@ -13,25 +13,25 @@ class TodoList: id: int owner: int name: str - _custom_id: Optional[str] + _custom_id: str | None status: str delete_done: bool - viewer: List[int] - editor: List[int] - created_at: Union[str, datetime] + viewer: list[int] + editor: list[int] + created_at: str | datetime spots: int views: int - todos: List[dict] - _bought: List[str] - thumbnail: Optional[str] - color: Optional[int] - description: Optional[str] + todos: list[dict] + _bought: list[str] + thumbnail: str | None + color: int | None + description: str | None - cache: ClassVar[Dict[int, TodoList]] = {} - custom_id_cache: ClassVar[Dict[str, int]] = {} + cache: ClassVar[dict[int, TodoList]] = {} + custom_id_cache: ClassVar[dict[str, int]] = {} @classmethod - def __get_cache(cls, list_id: Union[int, str]): + def __get_cache(cls, list_id: int | str): """Returns a cached object""" if isinstance(list_id, str) and not list_id.isdigit(): if list_id not in cls.custom_id_cache: @@ -40,7 +40,7 @@ def __get_cache(cls, list_id: Union[int, str]): return cls.cache[int(list_id)] if list_id in cls.cache else None @classmethod - async def new(cls, list_id: Union[int, str]) -> TodoList: + async def new(cls, list_id: int | str) -> TodoList: cached = cls.__get_cache(list_id) if cached is not None: return cached @@ -88,7 +88,7 @@ async def new(cls, list_id: Union[int, str]) -> TodoList: return td_list @property - def custom_id(self) -> Union[str, None]: + def custom_id(self) -> str | None: return self._custom_id @custom_id.setter @@ -258,15 +258,15 @@ class Todo: todo: str marked: str added_by: int - added_on: Union[str, datetime] + added_on: str | datetime views: int - assigned_to: List[int] - mark_log: List[dict] + assigned_to: list[int] + mark_log: list[dict] due_at: datetime = None notified: bool = None @classmethod - async def new(cls, position: Union[int, str], list_id: Union[int, str]) -> Todo: + async def new(cls, position: int | str, list_id: int | str) -> Todo: parent = await TodoList.new(list_id) task = parent.todos[int(position) - 1] diff --git a/killua/utils/classes/user.py b/killua/utils/classes/user.py index 4decbb1f9..cf9ae1751 100644 --- a/killua/utils/classes/user.py +++ b/killua/utils/classes/user.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, ClassVar, Dict, List, Optional, Union, cast, Literal, Tuple +from typing import Any, ClassVar, cast, Literal from dataclasses import dataclass from killua.static.constants import ( @@ -20,29 +20,29 @@ class User: id: int jenny: int daily_cooldown: datetime - met_user: List[int] - effects: Dict[str, Any] - rs_cards: List[List[int, Dict[str, Any]]] - fs_cards: List[List[int, Dict[str, Any]]] - _badges: List[str] - rps_stats: Dict[str, Dict[str, int]] - counting_highscore: Dict[str, int] - trivia_stats: Dict[str, Dict[str, int]] - achievements: List[str] + met_user: list[int] + effects: dict[str, Any] + rs_cards: list[list[int, dict[str, Any]]] + fs_cards: list[list[int, dict[str, Any]]] + _badges: list[str] + rps_stats: dict[str, dict[str, int]] + counting_highscore: dict[str, int] + trivia_stats: dict[str, dict[str, int]] + achievements: list[str] votes: int - voting_streak: Dict[str, int] + voting_streak: dict[str, int] voting_reminder: bool - premium_guilds: Dict[str, int] - lootboxes: List[int] - boosters: Dict[str, int] - weekly_cooldown: Optional[datetime] - action_settings: Dict[str, Any] - action_stats: Dict[str, Any] + premium_guilds: dict[str, int] + lootboxes: list[int] + boosters: dict[str, int] + weekly_cooldown: datetime | None + action_settings: dict[str, Any] + action_stats: dict[str, Any] locale: str has_user_installed: bool - email: Optional[str] - email_notifications: Dict[Literal["news", "updates", "posts"], bool] - cache: ClassVar[Dict[int, User]] = {} + email: str | None + email_notifications: dict[Literal["news", "updates", "posts"], bool] + cache: ClassVar[dict[int, User]] = {} async def set_email(self, email: str) -> None: """Sets the user's email address""" @@ -114,7 +114,7 @@ async def new(cls, user_id: int): return instance @property - def badges(self) -> List[str]: + def badges(self) -> list[str]: badges = ( self._badges.copy() ) # We do not want the badges added to _badges every time we call this property else it would add the same badge multiple times @@ -134,7 +134,7 @@ def badges(self) -> List[str]: return badges @property - def all_cards(self) -> List[int, dict]: + def all_cards(self) -> list[int, dict]: return [*self.rs_cards, *self.fs_cards] @property @@ -144,7 +144,7 @@ def is_premium(self) -> bool: return len([x for x in self.badges if x in PATREON_TIERS.keys()]) > 0 @property - def premium_tier(self) -> Union[str, None]: + def premium_tier(self) -> str | None: if len((res := [x for x in self.badges if x in PREMIUM_ALIASES.keys()])) > 0: return PREMIUM_ALIASES[res] return ( @@ -257,7 +257,7 @@ async def add_empty(cls, user_id: int, cards: bool = True) -> None: ) @staticmethod - async def get_top_collector() -> Optional[Tuple[int, int]]: + async def get_top_collector() -> tuple[int, int] | None: """ Returns the user id and the number of cards they have in their free slots of the user with the most non-fake cards in their free slots @@ -346,7 +346,7 @@ async def remove_badge(self, badge: str) -> None: self._badges.remove(badge.lower()) await self._update_val("badges", badge.lower(), "$pull") - async def set_badges(self, badges: List[str]) -> None: + async def set_badges(self, badges: list[str]) -> None: """Sets badges to anything""" self._badges = badges await self._update_val("badges", self._badges) @@ -429,7 +429,7 @@ async def set_action_settings(self, settings: dict) -> None: async def add_action( self, action: str, was_target: bool = False, amount: int = 1 - ) -> Optional[str]: + ) -> str | None: """Adds an action to the action stats. If a badge was a added, returns the name of the badge.""" if action not in self.action_stats: self.action_stats[action] = { @@ -455,7 +455,7 @@ async def add_action( def _has_card( self, - cards: List[list], + cards: list[list], card_id: int, fake_allowed: bool, only_allow_fakes: bool, @@ -514,11 +514,11 @@ async def set_jenny(self, amount: int) -> None: async def _find_match( self, - cards: List[list], + cards: list[list], card_id: int, - fake: Optional[bool], - clone: Optional[bool], - ) -> Tuple[Union[List[List[int, dict]], None], Union[List[int, dict], None]]: + fake: bool | None, + clone: bool | None, + ) -> tuple[list[list[int, dict]] | None, list[int, dict] | None]: counter = 0 while counter != len( cards @@ -543,7 +543,7 @@ async def _remove_logic( remove_fake: bool, clone: bool, no_exception: bool = False, - ) -> List[int, dict]: + ) -> list[int, dict]: """Handles the logic of the remove_card method""" attr = getattr(self, f"{card_type}_cards") cards, match = await self._find_match(attr, card_id, remove_fake, clone) @@ -567,7 +567,7 @@ async def remove_card( remove_fake: bool = None, restricted_slot: bool = None, clone: bool = None, - ) -> List[int, dict]: + ) -> list[int, dict]: """Removes a card from a user""" if self.has_any_card(card_id) is False: raise NotInPossession( @@ -587,7 +587,7 @@ async def remove_card( async def bulk_remove( self, - cards: List[List[int, dict]], + cards: list[list[int, dict]], fs_slots: bool = True, raise_if_failed: bool = False, ) -> None: @@ -646,7 +646,7 @@ async def add_multi(self, *args) -> None: fs_cards = [] rs_cards = [] - def fs_append(item: list): + def fs_append(item: list[Any]) -> list[Any]: if len([*self.fs_cards, *fs_cards]) >= 40: return fs_cards fs_cards.append(item) @@ -689,7 +689,7 @@ def can_swap(self, card_id: int) -> bool: else: return False # Returned if the requirements haven't been met - async def swap(self, card_id: int) -> Union[bool, None]: + async def swap(self, card_id: int) -> bool | None: """ Swaps a card from the free slots with one from the restricted slots. Usecase: swapping fake and real card @@ -728,7 +728,7 @@ async def remove_effect(self, effect: str): self.effects.pop(effect, None) await self._update_val("cards.effects", self.effects) - def has_effect(self, effect: str) -> Tuple[bool, Any]: + def has_effect(self, effect: str) -> tuple[bool, Any]: """Checks if a user has an effect and returns what effect if the user has it""" if effect in self.effects: return True, self.effects[effect] @@ -813,7 +813,7 @@ async def toggle_votereminder(self) -> None: self.voting_reminder = not self.voting_reminder await self._update_val("voting_reminder", not self.voting_reminder) - async def log_locale(self, locale: str) -> Optional[str]: + async def log_locale(self, locale: str) -> str | None: """Logs the locale of the user. Returns the old locale if it was different from the new one, else None""" if not self.locale or self.locale != locale: old = self.locale diff --git a/killua/utils/gif.py b/killua/utils/gif.py index 5d2223623..24ad1b599 100644 --- a/killua/utils/gif.py +++ b/killua/utils/gif.py @@ -5,7 +5,6 @@ # transparent pixels with black pixels (among other issues) when the GIF is saved using PIL.Image.save(). # This code works around the issue and allows us to properly generate transparent GIFs. -from typing import Tuple, List, Union from collections import defaultdict from random import randrange from itertools import chain @@ -127,11 +126,11 @@ def process(self) -> Image: def _create_animated_gif( - images: List[Image], durations: Union[int, List[int]] -) -> Tuple[Image, dict]: + images: list[Image], durations: int | list[int] +) -> tuple[Image, dict]: """If the image is a GIF, create an its thumbnail here.""" save_kwargs = dict() - new_images: List[Image] = [] + new_images: list[Image] = [] for frame in images: thumbnail = frame.copy() # type: Image @@ -155,7 +154,7 @@ def _create_animated_gif( def save_transparent_gif( - images: List[Image], durations: Union[int, List[int]], save_file + images: list[Image], durations: int | list[int], save_file ): """Creates a transparent GIF, adjusting to avoid transparency issues that are present in the PIL library @@ -163,7 +162,7 @@ def save_transparent_gif( Parameters: images: a list of PIL Image objects that compose the GIF frames - durations: an int or List[int] that describes the animation durations for the frames of this GIF + durations: an int or list[int] that describes the animation durations for the frames of this GIF save_file: A filename (string), pathlib.Path object or file object. (This parameter corresponds and is passed to the PIL.Image.save() method.) Returns: diff --git a/killua/utils/interactions.py b/killua/utils/interactions.py index b6d37b317..92e80a50a 100644 --- a/killua/utils/interactions.py +++ b/killua/utils/interactions.py @@ -1,12 +1,12 @@ import discord -from typing import Union, List, Any +from typing import Any class View(discord.ui.View): """Subclassing this for buttons enabled us to not have to define interaction_check anymore, also not if we want a select menu""" - def __init__(self, user_id: Union[int, List[int]], **kwargs): + def __init__(self, user_id: int | list[int], **kwargs): super().__init__(**kwargs) self.user_id = user_id self.value: Any = None @@ -26,7 +26,7 @@ async def interaction_check(self, interaction: discord.Interaction) -> bool: self.interaction = interaction # So we can respond to it anywhere return val - async def disable(self, msg: discord.Message) -> Union[discord.Message, None]: + async def disable(self, msg: discord.Message) -> discord.Message | None: """ "Disables the children inside of the view""" if not [ c for c in self.children if not c.disabled diff --git a/killua/utils/paginator.py b/killua/utils/paginator.py index 34b631b6e..88962f9c1 100644 --- a/killua/utils/paginator.py +++ b/killua/utils/paginator.py @@ -6,14 +6,14 @@ from inspect import iscoroutinefunction from .interactions import View -from typing import List, Union, Type, TypeVar, Coroutine, Tuple, Callable +from typing import Type, TypeVar, Coroutine, Callable E = TypeVar("E", discord.Embed, Type[discord.Embed]) R = TypeVar( "R", discord.Embed, Type[discord.Embed], - Tuple[Union[discord.Embed, Type[discord.Embed]], discord.File], + tuple[discord.Embed | Type[discord.Embed], discord.File], ) T = TypeVar("T") @@ -49,11 +49,11 @@ class Buttons(View): def __init__( self, user_id: int, - pages: Union[List[Union[str, int, dict]], None], + pages: list[str | int | dict] | None, timeout: int, page: int, max_pages: int, - func: Union[Callable[[int, E, T], R], Coroutine[int, E, T, R], None], + func: Callable[[int, E, T], R] | Coroutine[int, E, T, R] | None, embed: E, defer: bool, has_file: bool, @@ -198,11 +198,11 @@ class Paginator: def __init__( self, ctx: commands.Context, - pages: Union[List[Union[str, int, dict]], None] = None, - timeout: Union[int, float] = 200, + pages: list[str | int | dict] | None = None, + timeout: int | float = 200, page: int = 1, - max_pages: Union[int, None] = None, - func: Union[Callable[[int, E, T], R], Coroutine[int, E, T, R], None] = None, + max_pages: int | None = None, + func: Callable[[int, E, T], R] | Coroutine[int, E, T, R] | None = None, embed: E = None, defer: bool = False, # In case a pageturn can exceed 3 seconds this has to be set to True has_file: bool = False, diff --git a/killua/utils/test_db.py b/killua/utils/test_db.py index e3b1ca8a1..5bbe64eba 100644 --- a/killua/utils/test_db.py +++ b/killua/utils/test_db.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Dict +from typing import Any from copy import deepcopy from random import randint @@ -6,28 +6,28 @@ class AsyncCursor: """An async-iterable wrapper around a list, mimicking motor's AsyncIOMotorCursor.""" - def __init__(self, items: List[dict]): + def __init__(self, items: list[dict]): self._items = items self._index = 0 - def __aiter__(self): + def __aiter__(self) -> "AsyncCursor": self._index = 0 return self - async def __anext__(self): + async def __anext__(self) -> dict: if self._index >= len(self._items): raise StopAsyncIteration item = self._items[self._index] self._index += 1 return item - async def to_list(self, length=None): + async def to_list(self, length: int | None = None) -> list[dict]: if length is not None: return self._items[:length] return list(self._items) - def __await__(self): - async def _resolve(): + def __await__(self) -> Any: + async def _resolve() -> list[dict]: return self._items return _resolve().__await__() @@ -35,7 +35,7 @@ async def _resolve(): class TestingDatabase: """A database class imitating pymongos collection classes""" - db: Dict[str, List[dict]] = {} + db: dict[str, list[dict]] = {} @classmethod def reset_all(cls) -> None: @@ -52,7 +52,7 @@ def collection(self) -> str: return self._collection @staticmethod - def _resolve_path(obj: dict, dotted_key: str): + def _resolve_path(obj: dict, dotted_key: str) -> tuple[dict, str]: """Traverses *obj* along a dotted key and returns (parent, final_key).""" parts = dotted_key.split(".") for part in parts[:-1]: @@ -72,27 +72,28 @@ def _matches(self, doc: dict, where: dict) -> bool: return False return True - async def find_one(self, where: dict, **kwargs) -> Optional[dict]: + async def find_one(self, where: dict, **kwargs) -> dict | None: coll = self.db[self.collection] for d in coll: if self._matches(d, where): return deepcopy(d) + return None def find(self, where: dict, *args, **kwargs) -> AsyncCursor: coll = self.db[self.collection] results = [deepcopy(d) for d in coll if self._matches(d, where)] return AsyncCursor(results) - async def insert_one(self, object: dict) -> None: - if "_id" not in object: - object["_id"] = randint(0, 2**63) - self.db[self.collection].append(object) + async def insert_one(self, document: dict) -> None: + if "_id" not in document: + document["_id"] = randint(0, 2**63) + self.db[self.collection].append(document) - async def insert_many(self, objects: List[dict]) -> None: + async def insert_many(self, objects: list[dict]) -> None: for obj in objects: await self.insert_one(obj) - async def update_one(self, where: dict, update: Dict[str, dict]) -> dict: + async def update_one(self, where: dict, update: dict[str, dict]) -> dict: operator = list(update.keys())[0] for p, item in enumerate(self.db[self.collection]): @@ -113,7 +114,8 @@ async def update_one(self, where: dict, update: Dict[str, dict]) -> dict: return update - async def count_documents(self, where: dict = {}) -> int: + async def count_documents(self, where: dict | None = None) -> int: + where = where or {} return len([x async for x in self.find(where)]) async def delete_one(self, where: dict) -> None: diff --git a/killua/utils/topgg.py b/killua/utils/topgg.py index ccdaea311..8dbf99b6e 100644 --- a/killua/utils/topgg.py +++ b/killua/utils/topgg.py @@ -4,12 +4,19 @@ import logging import os -from typing import Optional +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aiohttp import ClientSession logger = logging.getLogger(__name__) TOPGG_METRICS_URL = "https://top.gg/api/v1/projects/@me/metrics" TOPGG_ANNOUNCEMENTS_URL = "https://top.gg/api/v1/projects/@me/announcements" +TOPGG_TITLE_MIN = 3 +TOPGG_TITLE_MAX = 100 +TOPGG_CONTENT_MIN = 10 +TOPGG_CONTENT_MAX = 2000 def _normalize_token(raw: str) -> str: @@ -21,7 +28,7 @@ def _normalize_token(raw: str) -> str: return token -def _token() -> Optional[str]: +def _token() -> str | None: value = os.getenv("TOPGG_TOKEN") if not value: return None @@ -49,7 +56,9 @@ def _log_auth_failure(method: str, url: str, status: int, body: str) -> None: ) -async def _request(session, method: str, url: str, *, json: Optional[dict] = None) -> bool: +async def _request( + session: ClientSession, method: str, url: str, *, json: dict | None = None +) -> bool: token = _token() if not token: logger.warning("TOPGG_TOKEN is not set; skipping Top.gg %s %s", method, url) @@ -70,10 +79,10 @@ async def _request(session, method: str, url: str, *, json: Optional[dict] = Non async def post_metrics( - session, + session: ClientSession, *, server_count: int, - shard_count: Optional[int] = None, + shard_count: int | None = None, ) -> bool: """Push guild/shard counts to Top.gg (v1 projects metrics).""" payload: dict[str, int] = {"server_count": server_count} @@ -87,18 +96,24 @@ async def post_metrics( return await _request(session, "PATCH", TOPGG_METRICS_URL, json=payload) -async def post_announcement(session, *, title: str, content: str) -> bool: +async def post_announcement( + session: ClientSession, *, title: str, content: str +) -> bool: """Create a Top.gg project announcement.""" - if len(title) < 3 or len(title) > 100: + if len(title) < TOPGG_TITLE_MIN or len(title) > TOPGG_TITLE_MAX: logger.warning( - "Top.gg announcement title length %d is outside 3–100; skipping", + "Top.gg announcement title length %d is outside %d–%d; skipping", len(title), + TOPGG_TITLE_MIN, + TOPGG_TITLE_MAX, ) return False - if len(content) < 10 or len(content) > 2000: + if len(content) < TOPGG_CONTENT_MIN or len(content) > TOPGG_CONTENT_MAX: logger.warning( - "Top.gg announcement content length %d is outside 10–2000; skipping", + "Top.gg announcement content length %d is outside %d–%d; skipping", len(content), + TOPGG_CONTENT_MIN, + TOPGG_CONTENT_MAX, ) return False return await _request( diff --git a/scripts/check_test_commands.py b/scripts/check_test_commands.py index 05789cd30..bbeb0afe2 100644 --- a/scripts/check_test_commands.py +++ b/scripts/check_test_commands.py @@ -28,8 +28,7 @@ from discord.ext.commands import Command from killua.tests.groups import tests -from killua.tests.testing import Testing, _test_class_command_name -from killua.tests.types import Bot +from killua.tests.testing import _test_class_command_name def _command_names_for_group(group_cls: type) -> set[str]: From a9985ae4e93ce50f7ab0449d4e57bc70f05e708d Mon Sep 17 00:00:00 2001 From: Kile Date: Sat, 23 May 2026 00:18:16 +0200 Subject: [PATCH 6/9] Fix security warnings --- api/Cargo.lock | 1867 +++++++++++++++++++++++++------------------- killua/cogs/api.py | 6 +- requirements.txt | 6 +- 3 files changed, 1071 insertions(+), 808 deletions(-) diff --git a/api/Cargo.lock b/api/Cargo.lock index 2e9213fc6..a07dc4e54 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -2,29 +2,14 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.2.15", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -32,19 +17,13 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -54,6 +33,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "api" version = "1.5.0" @@ -65,7 +50,7 @@ dependencies = [ "lazy_static", "load_file", "mongodb", - "rand 0.8.5", + "rand 0.8.6", "regex", "reqwest", "rocket", @@ -79,9 +64,9 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -90,24 +75,24 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "async-trait" -version = "0.1.80" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -118,39 +103,18 @@ checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" [[package]] name = "atomic" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ "bytemuck", ] [[package]] name = "autocfg" -version = "1.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" - -[[package]] -name = "backtrace" -version = "0.3.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -166,9 +130,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "binascii" @@ -184,9 +148,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -219,13 +183,13 @@ dependencies = [ "base64 0.22.1", "bitvec", "chrono", - "getrandom 0.2.15", - "getrandom 0.3.3", + "getrandom 0.2.17", + "getrandom 0.3.4", "hex", - "indexmap 2.9.0", + "indexmap", "js-sys", "once_cell", - "rand 0.9.1", + "rand 0.9.4", "serde", "serde_bytes", "serde_json", @@ -235,28 +199,29 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.16.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.6.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.16" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -274,23 +239,43 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.5", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", ] [[package]] @@ -314,16 +299,19 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] [[package]] name = "convert_case" -version = "0.4.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "cookie" @@ -346,21 +334,46 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam" version = "0.8.4" @@ -419,15 +432,15 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -440,7 +453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -457,14 +470,14 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "darling" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -472,34 +485,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.101", + "syn", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "data-encoding" -version = "2.6.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -513,12 +525,11 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", ] [[package]] @@ -529,38 +540,48 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "derive-where" -version = "1.4.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73f2692d4bd3cac41dca28934a39894200c9fabf49586d77d0e5954af1d7902" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "derive_more" -version = "0.99.17" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 1.0.109", + "syn", + "unicode-xid", ] [[package]] name = "devise" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6eacefd3f541c66fc61433d65e54e0e46e0a029a819a7dbbc7a7b489e8a85f8" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" dependencies = [ "devise_codegen", "devise_core", @@ -568,9 +589,9 @@ dependencies = [ [[package]] name = "devise_codegen" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8cf4b8dd484ede80fd5c547592c46c3745a617c8af278e2b72bea86b2dfed6" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" dependencies = [ "devise_core", "quote", @@ -578,15 +599,15 @@ dependencies = [ [[package]] name = "devise_core" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35b50dba0afdca80b187392b24f2499a88c336d5a8493e4b4ccfb608708be56a" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -602,9 +623,9 @@ dependencies = [ [[package]] name = "dircpy" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88521b0517f5f9d51d11925d8ab4523497dcf947073fa3231a311b63941131c" +checksum = "ebcbec2b9a580ddee352ac38523d2ecd4dcaad53532957034394556909e27f4b" dependencies = [ "jwalk", "log", @@ -619,7 +640,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -648,52 +669,40 @@ dependencies = [ [[package]] name = "either" -version = "1.12.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "fastrand" -version = "2.1.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fiat-crypto" @@ -707,7 +716,7 @@ version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "atomic 0.6.0", + "atomic 0.6.1", "pear", "serde", "toml", @@ -715,12 +724,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -738,9 +759,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -753,9 +774,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -767,9 +788,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -777,55 +798,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" - -[[package]] -name = "futures-executor" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -835,7 +845,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -864,48 +873,56 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] [[package]] -name = "gimli" -version = "0.28.1" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] [[package]] name = "glob" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -913,7 +930,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.9.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -922,15 +939,18 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -940,9 +960,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -951,22 +971,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hickory-proto" -version = "0.24.4" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", - "idna 1.0.3", + "hickory-proto", + "idna", "ipnet", - "once_cell", - "rand 0.8.5", + "jni", + "rand 0.10.1", "thiserror", "tinyvec", "tokio", @@ -974,22 +994,47 @@ dependencies = [ "url", ] +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hickory-resolver" -version = "0.24.4" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", - "lru-cache", + "ipnet", + "jni", + "moka", + "ndk-context", "once_cell", "parking_lot", - "rand 0.8.5", + "rand 0.10.1", "resolv-conf", "smallvec", + "system-configuration 0.7.0", "thiserror", "tokio", "tracing", @@ -1004,17 +1049,6 @@ dependencies = [ "digest", ] -[[package]] -name = "hostname" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" -dependencies = [ - "libc", - "match_cfg", - "winapi", -] - [[package]] name = "http" version = "0.2.12" @@ -1028,12 +1062,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1050,9 +1083,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1062,9 +1095,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.28" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ "bytes", "futures-channel", @@ -1077,7 +1110,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1099,14 +1132,15 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -1122,12 +1156,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1135,9 +1170,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1148,11 +1183,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1163,42 +1197,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1207,26 +1237,22 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "idna" -version = "0.5.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1235,9 +1261,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1245,24 +1271,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.9.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.17.1", "serde", + "serde_core", ] [[package]] @@ -1271,51 +1287,93 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags 2.5.0", - "cfg-if", - "libc", -] - [[package]] name = "ipconfig" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2", + "socket2 0.6.3", "widestring", - "windows-sys 0.48.0", - "winreg", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", ] [[package]] name = "ipnet" -version = "2.9.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] [[package]] name = "jobserver" @@ -1323,16 +1381,18 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1354,28 +1414,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.172" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "load_file" @@ -1385,19 +1445,18 @@ checksum = "31da4bce62428941030036a44d0d4b123f031b04080f8aeb44248b41de3e79cd" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.21" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loom" @@ -1414,15 +1473,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru-cache" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "macro_magic" version = "0.5.1" @@ -1432,7 +1482,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1446,7 +1496,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1457,7 +1507,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1468,22 +1518,16 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.101", + "syn", ] -[[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" - [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -1498,9 +1542,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1509,30 +1553,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.7.3" +name = "mio" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ - "adler", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "mio" -version = "1.0.3" +name = "moka" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] name = "mongocrypt" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22426d6318d19c5c0773f783f85375265d6a8f0fa76a733da8dc4355516ec63d" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" dependencies = [ "bson", "mongocrypt-sys", @@ -1542,28 +1594,26 @@ dependencies = [ [[package]] name = "mongocrypt-sys" -version = "0.1.4+1.12.0" +version = "0.1.5+1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda42df21d035f88030aad8e877492fac814680e1d7336a57b2a091b989ae388" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" [[package]] name = "mongodb" -version = "3.3.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f272c59e54a3c85f5902c6b8e7b1653a6b6681f45e4c42d6581301119a4b8" +checksum = "276ba0cd571553d1f6936c6f180964776ece6ab7507dc8765f8a9c9c49d8cd00" dependencies = [ - "async-trait", - "base64 0.13.1", - "bitflags 1.3.2", + "base64 0.22.1", + "bitflags 2.11.1", "bson", - "chrono", "derive-where", "derive_more", "futures-core", - "futures-executor", "futures-io", "futures-util", "hex", + "hickory-net", "hickory-proto", "hickory-resolver", "hmac", @@ -1571,19 +1621,17 @@ dependencies = [ "md-5", "mongocrypt", "mongodb-internal-macros", - "once_cell", "pbkdf2", "percent-encoding", - "rand 0.8.5", + "rand 0.9.4", "rustc_version_runtime", "rustls", - "rustversion", "serde", "serde_bytes", "serde_with", "sha1", "sha2", - "socket2", + "socket2 0.6.3", "stringprep", "strsim", "take_mut", @@ -1593,19 +1641,19 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "mongodb-internal-macros" -version = "3.3.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63981427a0f26b89632fd2574280e069d09fb2912a3138da15de0174d11dd077" +checksum = "99ceb1a9a1018e470077ec94cf3a8c2d0e6da542b2c05ea95a59a0a627147375" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1617,7 +1665,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.3.1", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1629,9 +1677,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -1644,21 +1692,26 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1671,40 +1724,34 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ "hermit-abi", "libc", ] [[package]] -name = "object" -version = "0.32.2" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ - "memchr", + "critical-section", + "portable-atomic", ] -[[package]] -name = "once_cell" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" - [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1717,20 +1764,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -1738,17 +1785,11 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking_lot" -version = "0.12.2" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1756,22 +1797,22 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.5", + "windows-link", ] [[package]] name = "pbkdf2" -version = "0.11.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", ] @@ -1796,26 +1837,20 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs8" @@ -1829,15 +1864,21 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1850,15 +1891,39 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1871,31 +1936,31 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", "version_check", "yansi", ] -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quote" -version = "1.0.36" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radium" @@ -1905,9 +1970,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1916,12 +1981,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1941,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1950,23 +2026,29 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1984,76 +2066,61 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.1" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", ] [[package]] name = "ref-cast" -version = "1.0.23" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.23" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + "regex-syntax", +] [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -2084,7 +2151,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tower-service", @@ -2097,23 +2164,19 @@ dependencies = [ [[package]] name = "resolv-conf" -version = "0.7.0" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" -dependencies = [ - "hostname", - "quick-error", -] +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "ring" -version = "0.17.13" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2133,14 +2196,14 @@ dependencies = [ "either", "figment", "futures", - "indexmap 2.9.0", + "indexmap", "log", "memchr", "multer", "num_cpus", "parking_lot", "pin-project-lite", - "rand 0.8.5", + "rand 0.8.6", "ref-cast", "rocket_codegen", "rocket_http", @@ -2165,11 +2228,11 @@ checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" dependencies = [ "devise", "glob", - "indexmap 2.9.0", + "indexmap", "proc-macro2", "quote", "rocket_http", - "syn 2.0.101", + "syn", "unicode-xid", "version_check", ] @@ -2185,7 +2248,7 @@ dependencies = [ "futures", "http 0.2.12", "hyper", - "indexmap 2.9.0", + "indexmap", "log", "memchr", "pear", @@ -2201,17 +2264,11 @@ dependencies = [ "uncased", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc_version" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] @@ -2228,22 +2285,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.34" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.32" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -2265,18 +2322,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2291,9 +2348,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2306,11 +2363,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.23" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2327,12 +2384,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.5.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2340,9 +2397,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -2350,50 +2407,62 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.23" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] name = "serde_bytes" -version = "0.11.14" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b8497c313fd43ab992087548117643f6fcd935cbf36f176ffda0aacf9591734" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap 2.9.0", + "indexmap", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -2419,32 +2488,24 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.9.0", - "serde", - "serde_derive", - "serde_json", + "serde_core", "serde_with_macros", - "time", ] [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -2454,7 +2515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2465,7 +2526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2486,10 +2547,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2503,30 +2565,53 @@ dependencies = [ ] [[package]] -name = "slab" -version = "0.4.9" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "autocfg", + "rustc_version", + "simdutf8", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -2554,9 +2639,9 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "state" @@ -2586,26 +2671,15 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" - -[[package]] -name = "syn" -version = "1.0.109" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.101" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2626,7 +2700,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -2636,8 +2710,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -2650,6 +2735,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -2663,6 +2758,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -2683,72 +2784,72 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.10.1" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.4.2", + "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "thiserror" -version = "1.0.61" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2765,9 +2866,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2775,9 +2876,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2790,32 +2891,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2", + "socket2 0.6.3", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -2840,9 +2938,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.15" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2851,14 +2949,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -2890,7 +2989,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.9.0", + "indexmap", "serde", "serde_spanned", "toml_datetime", @@ -2906,15 +3005,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2923,20 +3022,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2955,14 +3054,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -2979,29 +3078,29 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typed-builder" -version = "0.20.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.20.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "typenum" -version = "1.17.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ubyte" @@ -3024,36 +3123,42 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.1" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -3063,13 +3168,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.0" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 0.5.0", + "idna", "percent-encoding", + "serde", ] [[package]] @@ -3080,20 +3186,21 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.8.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.2.15", - "serde", + "getrandom 0.4.2", + "js-sys", + "serde_core", "wasm-bindgen", ] [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" @@ -3109,9 +3216,9 @@ checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" @@ -3134,63 +3241,56 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.101", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3198,75 +3298,84 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.101", - "wasm-bindgen-backend", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.77" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "js-sys", - "wasm-bindgen", + "leb128fmt", + "wasmparser", ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "webpki-roots 1.0.3", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "webpki-roots" -version = "1.0.3" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "rustls-pki-types", + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "widestring" -version = "1.1.0" +name = "web-sys" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "winapi" -version = "0.3.9" +name = "webpki-roots" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "rustls-pki-types", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "widestring" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "winapi-util" @@ -3274,15 +3383,9 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.48.0" @@ -3294,11 +3397,72 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.5", + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] @@ -3316,7 +3480,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -3336,18 +3509,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -3358,9 +3531,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -3370,9 +3543,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -3382,15 +3555,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -3400,9 +3573,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -3412,9 +3585,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -3424,9 +3597,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -3436,15 +3609,15 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.10" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -3460,19 +3633,104 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bitflags 2.5.0", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -3494,11 +3752,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3506,62 +3763,62 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.7.34" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.34" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zeromq-src" @@ -3575,9 +3832,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3586,9 +3843,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3597,15 +3854,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zmq" version = "0.10.0" diff --git a/killua/cogs/api.py b/killua/cogs/api.py index b904a3d48..7cc851896 100644 --- a/killua/cogs/api.py +++ b/killua/cogs/api.py @@ -787,9 +787,9 @@ async def user_info(self, data: dict) -> dict: "email_notifications": user_data.email_notifications, } - if data.get("from_admin", False) is False: + if not data.get("from_admin", False): # Fire and forget background task - create_task(self._register_login(user, user_data)) + _login_task = create_task(self._register_login(user, user_data)) return self.jsonify(response_data) @@ -1262,7 +1262,7 @@ async def guild_command_usage(self, data: dict) -> dict | list: # pragma: no co formatted.append( { "name": res["metric"]["command"], - "group": res["metric"].get("group", None), + "group": cast(dict, res["metric"]).get("group", ""), "command_id": int(res["metric"]["command_id"]), "values": [(str(timestamp), int(value)) for timestamp, value in res["values"]] } diff --git a/requirements.txt b/requirements.txt index d5cfadcf4..1562a4030 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ aiohappyeyeballs==2.5.0 -aiohttp==3.13.3 +aiohttp==3.13.5 aiosignal==1.4.0 async-timeout==5.0.1 attrs==25.2.0 @@ -12,7 +12,7 @@ discord.py==2.6.4 dnspython==2.7.0 fonttools==4.60.2 frozenlist==1.5.0 -idna==3.10 +idna==3.16 import_expression==2.2.1.post1 importlib_metadata==8.5.0 importlib_resources==6.4.5 @@ -23,7 +23,7 @@ multidict==6.1.0 numpy==2.0.2; python_version < "3.13" numpy>=2.1.0; python_version >= "3.13" packaging==25.0 -pillow==11.0.0 +pillow==12.2.0 prometheus_client==0.22.1 propcache==0.3.1 psutil==7.0.0 From 4aa7de3a3fa4487d7465b5b64f0a9fbfc75dfd28 Mon Sep 17 00:00:00 2001 From: Kile Date: Sat, 23 May 2026 00:30:42 +0200 Subject: [PATCH 7/9] Fix clippy screaming --- api/src/routes/news.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/routes/news.rs b/api/src/routes/news.rs index c2089f2ef..1c7cc7cbd 100644 --- a/api/src/routes/news.rs +++ b/api/src/routes/news.rs @@ -107,7 +107,7 @@ pub async fn get_news( // Sort by timestamp descending (newest first) let mut sorted_items = news_items; - sorted_items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + sorted_items.sort_by_key(|item| std::cmp::Reverse(item.timestamp)); // If the user is not authenticated or if they are authenticated but not an admin, filter out drafts if auth.is_none() || (auth.is_some() && !auth.as_ref().unwrap().is_admin()) { From baf4479454607b8b0e0f47b82ea20fba047972b3 Mon Sep 17 00:00:00 2001 From: Kile Date: Sat, 23 May 2026 16:25:09 +0200 Subject: [PATCH 8/9] Fix sometimes failing test on gh actions --- api/src/routes/image.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api/src/routes/image.rs b/api/src/routes/image.rs index dcb8a2c1e..7bd1492d9 100644 --- a/api/src/routes/image.rs +++ b/api/src/routes/image.rs @@ -223,6 +223,15 @@ pub async fn upload( )); } + // Tokio fs writes are dispatched to spawn_blocking; flush waits for completion + // before the handler returns and tests/clients read the file synchronously. + if file.flush().await.is_err() { + return Err(( + rocket::http::Status::InternalServerError, + Json(serde_json::json!({"error": "Failed to flush file"})), + )); + } + Ok(Json(serde_json::json!({ "success": true, "message": "File uploaded successfully", From 4641e1b5f7383a4851683b8eab5986cca7d39aa0 Mon Sep 17 00:00:00 2001 From: Kile Date: Sat, 23 May 2026 17:45:02 +0200 Subject: [PATCH 9/9] Bye SonarQube --- .github/workflows/sonarqube-analysis.yml | 38 ------------------------ 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/sonarqube-analysis.yml diff --git a/.github/workflows/sonarqube-analysis.yml b/.github/workflows/sonarqube-analysis.yml deleted file mode 100644 index 5fd5f4a43..000000000 --- a/.github/workflows/sonarqube-analysis.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Build -on: - push: - branches: - - main - pull_request: - types: [opened, synchronize, reopened] -jobs: - sonarqube: - name: SonarQube - runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt -r requirements-dev.txt - - - name: Download cards for tests - run: python -m killua --download public - - - name: Run tests and export coverage for Sonar - run: | - coverage run -m killua -t - coverage xml - - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v6 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}