├── .prettierignore ├── assets └── logo.png ├── nonebug ├── app.py ├── __init__.py ├── mixin │ ├── __init__.py │ ├── call_api │ │ ├── model.py │ │ ├── fake.py │ │ └── __init__.py │ ├── process │ │ ├── model.py │ │ ├── fake.py │ │ └── __init__.py │ ├── dependent.py │ └── driver.py ├── base.py ├── fixture.py └── provider.py ├── tests ├── test_fixture.py ├── test_app.py ├── conftest.py ├── plugins │ └── process.py ├── test_dependent.py ├── test_driver.py ├── test_process.py ├── utils.py └── test_call_api.py ├── .prettierrc ├── .github ├── workflows │ ├── ruff.yml │ ├── codecov.yml │ └── release.yml └── actions │ └── setup-python │ └── action.yml ├── .pre-commit-config.yaml ├── .editorconfig ├── LICENSE ├── .devcontainer └── devcontainer.json ├── README.md ├── pyproject.toml └── .gitignore /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/**/*.md 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonebot/nonebug/HEAD/assets/logo.png -------------------------------------------------------------------------------- /nonebug/app.py: -------------------------------------------------------------------------------- 1 | from .mixin import CallApiMixin, DependentMixin, DriverMixin, ProcessMixin 2 | 3 | 4 | class App(DependentMixin, ProcessMixin, CallApiMixin, DriverMixin): ... 5 | -------------------------------------------------------------------------------- /tests/test_fixture.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | 3 | 4 | def test_custom_init(nonebug_init): 5 | config = nonebot.get_driver().config 6 | 7 | assert config.custom_key == "custom_value" 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "endOfLine": "lf", 5 | "arrowParens": "always", 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "semi": true 9 | } 10 | -------------------------------------------------------------------------------- /nonebug/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | NONEBOT_INIT_KWARGS = pytest.StashKey[dict[str, Any]]() 6 | NONEBOT_START_LIFESPAN = pytest.StashKey[bool]() 7 | 8 | from .app import App as App 9 | -------------------------------------------------------------------------------- /nonebug/mixin/__init__.py: -------------------------------------------------------------------------------- 1 | from .call_api import CallApiMixin as CallApiMixin 2 | from .dependent import DependentMixin as DependentMixin 3 | from .driver import DriverMixin as DriverMixin 4 | from .process import ProcessMixin as ProcessMixin 5 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from nonebug import App 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_app(app: App): 8 | with pytest.raises(RuntimeError, match="Another"): # noqa: PT012 9 | async with app.test_api(): 10 | async with app.test_api(): 11 | ... 12 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | paths: 9 | - "nonebot/**" 10 | - "packages/**" 11 | - "tests/**" 12 | 13 | jobs: 14 | ruff: 15 | name: Ruff Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | 20 | - name: Run Ruff Lint 21 | uses: astral-sh/ruff-action@v3 -------------------------------------------------------------------------------- /.github/actions/setup-python/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Python 2 | description: Setup Python 3 | 4 | inputs: 5 | python-version: 6 | description: Python version 7 | required: false 8 | default: "3.12" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - uses: astral-sh/setup-uv@v7 14 | with: 15 | python-version: ${{ inputs.python-version }} 16 | 17 | - run: uv sync --locked 18 | shell: bash 19 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import nonebot 4 | from nonebot.plugin import Plugin 5 | import pytest 6 | 7 | from nonebug import NONEBOT_INIT_KWARGS 8 | from nonebug.fixture import _nonebot_init, nonebug_app, nonebug_init # noqa: F401 9 | 10 | 11 | def pytest_configure(config: pytest.Config) -> None: 12 | config.stash[NONEBOT_INIT_KWARGS] = {"custom_key": "custom_value"} 13 | 14 | 15 | @pytest.fixture(scope="session", autouse=True) 16 | async def after_nonebot_init(_nonebot_init: None) -> set[Plugin]: # noqa: F811 17 | return nonebot.load_plugins(str(Path(__file__).parent / "plugins")) 18 | -------------------------------------------------------------------------------- /nonebug/mixin/call_api/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Any, Optional, Union 3 | 4 | if TYPE_CHECKING: 5 | from nonebot.adapters import Adapter, Bot, Event, Message, MessageSegment 6 | 7 | 8 | @dataclass 9 | class Model: ... 10 | 11 | 12 | @dataclass 13 | class Api(Model): 14 | name: str 15 | data: dict[str, Any] 16 | result: Any 17 | exception: Optional[Exception] 18 | adapter: Optional["Adapter"] 19 | 20 | 21 | @dataclass 22 | class Send(Model): 23 | event: "Event" 24 | message: Union[str, "Message", "MessageSegment"] 25 | kwargs: dict[str, Any] 26 | result: Any 27 | exception: Optional[Exception] 28 | bot: Optional["Bot"] 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, prepare-commit-msg] 2 | ci: 3 | autofix_commit_msg: ":rotating_light: auto fix by pre-commit hooks" 4 | autofix_prs: true 5 | autoupdate_branch: master 6 | autoupdate_schedule: monthly 7 | autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks" 8 | repos: 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.14.7 11 | hooks: 12 | - id: ruff-check 13 | args: [--fix, --exit-non-zero-on-fix] 14 | stages: [pre-commit] 15 | - id: ruff-format 16 | stages: [pre-commit] 17 | 18 | - repo: https://github.com/nonebot/nonemoji 19 | rev: v0.1.4 20 | hooks: 21 | - id: nonemoji 22 | stages: [prepare-commit-msg] 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # The JSON files contain newlines inconsistently 13 | [*.json] 14 | insert_final_newline = ignore 15 | 16 | # Minified JavaScript files shouldn't be changed 17 | [**.min.js] 18 | indent_style = ignore 19 | insert_final_newline = ignore 20 | 21 | # Makefiles always use tabs for indentation 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Batch files use tabs for indentation 26 | [*.bat] 27 | indent_style = tab 28 | 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | 32 | # Matches the exact files either package.json or .travis.yml 33 | [{package.json,.travis.yml}] 34 | indent_size = 2 35 | 36 | [{*.py,*.pyi}] 37 | indent_size = 4 38 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Test Coverage 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 17 | env: 18 | PYTHON_VERSION: ${{ matrix.python-version }} 19 | 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - name: Setup Python environment 24 | uses: ./.github/actions/setup-python 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Run Pytest 29 | run: uv run --no-sync pytest --cov-report xml 30 | 31 | - name: Upload coverage report 32 | uses: codecov/codecov-action@v5 33 | with: 34 | env_vars: PYTHON_VERSION 35 | files: ./coverage.xml 36 | flags: unittests 37 | fail_ci_if_error: true 38 | env: 39 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 NoneBot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v6 16 | 17 | - name: Setup Python environment 18 | uses: ./.github/actions/setup-python 19 | 20 | - name: Get Version 21 | id: version 22 | run: | 23 | echo "VERSION=$(uv version --short)" >> $GITHUB_OUTPUT 24 | echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 25 | echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 26 | 27 | - name: Check Version 28 | if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 29 | run: exit 1 30 | 31 | - name: Build and Publish Package 32 | run: | 33 | uv build 34 | uv publish 35 | 36 | - name: Publish Package to GitHub 37 | run: gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /tests/plugins/process.py: -------------------------------------------------------------------------------- 1 | from nonebot import on_message 2 | from nonebot.adapters import Bot 3 | from nonebot.permission import Permission 4 | 5 | test = on_message(rule=lambda: True, permission=Permission(lambda: True), block=True) 6 | 7 | 8 | @test.handle() 9 | async def _(bot: Bot): 10 | result = await test.send("test_send") 11 | assert result == "result" 12 | result = await bot.call_api("test", key="value") 13 | assert result == "result" 14 | await test.pause() 15 | 16 | 17 | @test.handle() 18 | async def _(): 19 | await test.reject() 20 | 21 | 22 | test_not_pass_perm = on_message( 23 | rule=lambda: True, permission=Permission(lambda: False), block=True 24 | ) 25 | test_not_pass_rule = on_message( 26 | rule=lambda: False, permission=Permission(lambda: True), block=True 27 | ) 28 | 29 | 30 | test_ignore = on_message( 31 | rule=lambda: False, permission=Permission(lambda: False), block=True 32 | ) 33 | 34 | 35 | @test_ignore.permission_updater 36 | async def _(): 37 | return Permission(lambda: True) 38 | 39 | 40 | @test_ignore.got("key", prompt="key") 41 | async def _(): 42 | await test_ignore.finish("message") 43 | 44 | 45 | test_error = on_message(priority=100) 46 | 47 | 48 | @test_error.handle() 49 | async def _(): 50 | await test_error.finish("") 51 | -------------------------------------------------------------------------------- /nonebug/base.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from typing import Optional 3 | from typing_extensions import Self 4 | 5 | 6 | class Context: 7 | def __init__(self, app: "BaseApp", *args, **kwargs): 8 | self.app = app 9 | if self.app.context is not None: 10 | raise RuntimeError("Another test context is actived") 11 | self.app.context = self 12 | 13 | self.stack = contextlib.AsyncExitStack() 14 | 15 | async def __aenter__(self) -> Self: 16 | await self.stack.__aenter__() 17 | await self.setup() 18 | return self 19 | 20 | async def __aexit__(self, exc_type, exc_val, exc_tb): 21 | try: 22 | await self.run() 23 | finally: 24 | await self.stack.__aexit__(exc_type, exc_val, exc_tb) 25 | self.app.context = None 26 | 27 | async def setup(self) -> None: 28 | pass 29 | 30 | async def run(self) -> None: 31 | pass 32 | 33 | 34 | class BaseApp: 35 | def __init__(self): 36 | from nonebot.matcher import matchers 37 | 38 | from .provider import NoneBugProvider 39 | 40 | self.context: Optional[Context] = None 41 | if not isinstance(matchers.provider, NoneBugProvider): # pragma: no cover 42 | raise RuntimeError("NoneBug is not initialized") 43 | self.provider = matchers.provider 44 | -------------------------------------------------------------------------------- /nonebug/mixin/process/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, ClassVar, Optional, Union 3 | 4 | from _pytest.outcomes import OutcomeException 5 | 6 | if TYPE_CHECKING: 7 | from nonebot.adapters import Bot, Event 8 | from nonebot.matcher import Matcher 9 | 10 | 11 | @dataclass 12 | class Model: ... 13 | 14 | 15 | @dataclass 16 | class ReceiveEvent(Model): 17 | bot: "Bot" 18 | event: "Event" 19 | 20 | 21 | @dataclass 22 | class Action(Model): 23 | matcher: Optional[type["Matcher"]] = None 24 | 25 | 26 | @dataclass 27 | class Paused(Action): ... 28 | 29 | 30 | @dataclass 31 | class Rejected(Action): ... 32 | 33 | 34 | @dataclass 35 | class Finished(Action): ... 36 | 37 | 38 | @dataclass 39 | class Check(Model): 40 | matcher: Optional[type["Matcher"]] = None 41 | 42 | _priority: ClassVar[int] 43 | 44 | @property 45 | def priority(self) -> int: 46 | return self._priority + 100 * (self.matcher is None) 47 | 48 | 49 | @dataclass 50 | class RulePass(Check): 51 | _priority: ClassVar[int] = 3 52 | 53 | 54 | @dataclass 55 | class RuleNotPass(Check): 56 | _priority: ClassVar[int] = 2 57 | 58 | 59 | @dataclass 60 | class IgnoreRule(Check): 61 | _priority: ClassVar[int] = 1 62 | 63 | 64 | @dataclass 65 | class PermissionPass(Check): 66 | _priority: ClassVar[int] = 3 67 | 68 | 69 | @dataclass 70 | class PermissionNotPass(Check): 71 | _priority: ClassVar[int] = 2 72 | 73 | 74 | @dataclass 75 | class IgnorePermission(Check): 76 | _priority: ClassVar[int] = 1 77 | 78 | 79 | @dataclass 80 | class Error: 81 | matcher: type["Matcher"] 82 | error: Union[Exception, OutcomeException] 83 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ubuntu", 3 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 4 | "features": { 5 | "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, 6 | "ghcr.io/meaningful-ooo/devcontainer-features/fish:2": {} 7 | }, 8 | "postCreateCommand": "uv sync && uv run pre-commit install", 9 | "customizations": { 10 | "vscode": { 11 | "settings": { 12 | "python.analysis.diagnosticMode": "workspace", 13 | "[python]": { 14 | "editor.defaultFormatter": "charliermarsh.ruff", 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.ruff": "explicit", 17 | "source.organizeImports": "explicit" 18 | } 19 | }, 20 | "[javascript]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | }, 23 | "[html]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | "[typescript]": { 27 | "editor.defaultFormatter": "esbenp.prettier-vscode" 28 | }, 29 | "[javascriptreact]": { 30 | "editor.defaultFormatter": "esbenp.prettier-vscode" 31 | }, 32 | "[typescriptreact]": { 33 | "editor.defaultFormatter": "esbenp.prettier-vscode" 34 | }, 35 | "files.exclude": { 36 | "**/__pycache__": true 37 | }, 38 | "files.watcherExclude": { 39 | "**/target/**": true, 40 | "**/__pycache__": true 41 | } 42 | }, 43 | "extensions": [ 44 | "ms-python.python", 45 | "ms-python.vscode-pylance", 46 | "charliermarsh.ruff", 47 | "EditorConfig.EditorConfig", 48 | "esbenp.prettier-vscode" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/test_dependent.py: -------------------------------------------------------------------------------- 1 | from exceptiongroup import BaseExceptionGroup 2 | from nonebot.adapters import Event 3 | from nonebot.exception import TypeMisMatch 4 | from nonebot.params import EventParam 5 | import pytest 6 | 7 | from nonebug import App 8 | from tests.utils import make_fake_event 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_dependent(app: App): 13 | FakeEvent = make_fake_event(test_field=(str, "test")) 14 | FakeEvent2 = make_fake_event(test_field2=(str, "test2")) 15 | 16 | def _handle(event: Event): ... 17 | 18 | def _handle_fake(event: FakeEvent): # type: ignore 19 | ... 20 | 21 | async with app.test_dependent(_handle, allow_types=[EventParam]) as ctx: 22 | ctx.pass_params(event=FakeEvent()) 23 | async with app.test_dependent(_handle_fake, allow_types=[EventParam]) as ctx: 24 | ctx.pass_params(event=FakeEvent()) 25 | 26 | event = FakeEvent2() 27 | with pytest.raises((TypeMisMatch, BaseExceptionGroup)) as exc_info: 28 | async with app.test_dependent(_handle_fake, allow_types=[EventParam]) as ctx: 29 | ctx.pass_params(event=event) 30 | 31 | if isinstance(exc_info.value, BaseExceptionGroup): 32 | assert exc_info.group_contains(TypeMisMatch) 33 | exc_group = exc_info.value.subgroup(TypeMisMatch) 34 | e = exc_group.exceptions[0] if exc_group else None 35 | assert isinstance(e, TypeMisMatch) 36 | else: 37 | e = exc_info.value 38 | 39 | assert e.param.name == "event" 40 | assert e.value is event 41 | 42 | 43 | @pytest.mark.asyncio 44 | @pytest.mark.xfail(strict=True) 45 | async def test_should_fail(app: App): 46 | def _handle_return(): 47 | return True 48 | 49 | async with app.test_dependent(_handle_return) as ctx: 50 | ctx.should_return(False) 51 | -------------------------------------------------------------------------------- /tests/test_driver.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import nonebot 4 | from nonebot.adapters import Adapter, Bot, Event, Message, MessageSegment 5 | from nonebot.drivers import URL, Driver, HTTPServerSetup, Request, Response 6 | import pytest 7 | 8 | from nonebug import App 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_driver(app: App): 13 | class FakeBot(Bot): 14 | async def send( 15 | self, 16 | event: Event, 17 | message: Union[str, Message, MessageSegment], 18 | **kwargs, 19 | ): 20 | raise NotImplementedError 21 | 22 | class FakeAdapter(Adapter): 23 | def __init__(self, driver: Driver, **kwargs): 24 | super().__init__(driver, **kwargs) 25 | setup = HTTPServerSetup(URL("/test"), "POST", "test", self.handle) 26 | self.setup_http_server(setup) 27 | 28 | @classmethod 29 | def get_name(cls) -> str: 30 | return "fake" 31 | 32 | async def handle(self, request: Request) -> Response: 33 | assert request.content == b"test" 34 | bot = FakeBot(self, "test") 35 | result = await bot.call_api("test", test="test") 36 | assert result == "result" 37 | return Response(200, content="test") 38 | 39 | async def _call_api(self, bot: Bot, api: str, **data): 40 | assert bot.self_id == "test" 41 | assert api == "test" 42 | assert data == {"test": "test"} 43 | return "result" 44 | 45 | driver = nonebot.get_driver() 46 | driver.register_adapter(FakeAdapter) 47 | 48 | async with app.test_server() as ctx: 49 | client = ctx.get_client() 50 | 51 | res = await client.post("/test", data="test") 52 | assert res.status_code == 200 53 | assert res.text == "test" 54 | -------------------------------------------------------------------------------- /nonebug/mixin/call_api/fake.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, overload 2 | from typing_extensions import override 3 | 4 | if TYPE_CHECKING: 5 | from nonebot.adapters import Adapter, Bot, Event, Message, MessageSegment 6 | 7 | from . import ApiContext 8 | 9 | A = TypeVar("A", bound=type["Adapter"]) 10 | B = TypeVar("B", bound=type["Bot"]) 11 | 12 | 13 | @overload 14 | def make_fake_adapter(ctx: "ApiContext", base: None = None) -> type["Adapter"]: ... 15 | 16 | 17 | @overload 18 | def make_fake_adapter(ctx: "ApiContext", base: A) -> A: ... 19 | 20 | 21 | # fake class should be created every init 22 | def make_fake_adapter( 23 | ctx: "ApiContext", base: Optional[A] = None 24 | ) -> Union[A, type["Adapter"]]: 25 | from nonebot.adapters import Adapter 26 | 27 | _base = base or Adapter 28 | 29 | class FakeAdapter(_base): # type: ignore 30 | @classmethod 31 | @override 32 | def get_name(cls) -> str: # type: ignore 33 | return "fake" 34 | 35 | @override 36 | async def _call_api(self, bot: "Bot", api: str, **data) -> Any: # type: ignore 37 | return ctx.got_call_api(self, api, **data) 38 | 39 | return FakeAdapter 40 | 41 | 42 | @overload 43 | def make_fake_bot(ctx: "ApiContext", base: None = None) -> type["Bot"]: ... 44 | 45 | 46 | @overload 47 | def make_fake_bot(ctx: "ApiContext", base: B) -> B: ... 48 | 49 | 50 | def make_fake_bot(ctx: "ApiContext", base: Optional[B] = None) -> Union[B, type["Bot"]]: 51 | from nonebot.adapters import Bot 52 | 53 | _base = base or Bot 54 | 55 | class FakeBot(_base): # type: ignore 56 | @override 57 | async def send( # type: ignore 58 | self, 59 | event: "Event", 60 | message: Union[str, "Message", "MessageSegment"], 61 | **kwargs, 62 | ) -> Any: 63 | return ctx.got_call_send(self, event, message, **kwargs) 64 | 65 | return FakeBot 66 | -------------------------------------------------------------------------------- /nonebug/mixin/dependent.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from contextlib import AsyncExitStack 3 | from typing import TYPE_CHECKING, Any, Callable, Optional, Union 4 | from typing_extensions import final 5 | 6 | import pytest 7 | 8 | from nonebug.base import BaseApp 9 | 10 | from .call_api import ApiContext 11 | 12 | if TYPE_CHECKING: 13 | from nonebot.dependencies import Dependent, Param 14 | 15 | 16 | UNSET = object() 17 | 18 | 19 | @final 20 | class DependentContext(ApiContext): 21 | def __init__( 22 | self, 23 | app: "DependentMixin", 24 | *args, 25 | dependent: "Dependent", 26 | **kwargs, 27 | ): 28 | super().__init__(app, *args, **kwargs) 29 | self.dependent = dependent 30 | self.kwargs: dict[str, Any] = {} 31 | 32 | def pass_params(self, **kwargs: Any) -> None: 33 | self.kwargs.update(kwargs) 34 | 35 | def should_return(self, result: Any) -> None: 36 | self.result = result 37 | 38 | async def run(self): 39 | stack = AsyncExitStack() 40 | async with stack: 41 | result = await self.dependent(stack=stack, **self.kwargs) 42 | if ( 43 | expected := getattr(self, "result", UNSET) 44 | ) is not UNSET and result != expected: 45 | pytest.fail( 46 | f"Dependent got return value {result!r} but expected {expected!r}" 47 | ) 48 | 49 | 50 | class DependentMixin(BaseApp): 51 | def test_dependent( 52 | self, 53 | dependent: Union["Dependent", Callable[..., Any]], 54 | allow_types: Optional[Iterable[type["Param"]]] = None, 55 | parameterless: Optional[Iterable[Any]] = None, 56 | ) -> DependentContext: 57 | from nonebot.dependencies import Dependent 58 | 59 | if not isinstance(dependent, Dependent): 60 | dependent = Dependent[Any].parse( 61 | call=dependent, 62 | parameterless=parameterless, 63 | allow_types=allow_types or (), 64 | ) 65 | 66 | return DependentContext(self, dependent=dependent) 67 | 68 | test_handler = test_dependent 69 | -------------------------------------------------------------------------------- /nonebug/mixin/driver.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from typing_extensions import final 3 | 4 | from asgiref.typing import ASGIApplication 5 | from async_asgi_testclient import TestClient 6 | 7 | from nonebug.base import BaseApp, Context 8 | 9 | _global_client: Optional[TestClient] = None 10 | 11 | 12 | def set_global_client(client: TestClient): 13 | global _global_client 14 | 15 | if _global_client is not None: 16 | raise RuntimeError() 17 | 18 | _global_client = client 19 | 20 | 21 | def get_global_client() -> Optional[TestClient]: 22 | return _global_client 23 | 24 | 25 | @final 26 | class ServerContext(Context): 27 | def __init__( 28 | self, 29 | app: BaseApp, 30 | *args, 31 | asgi: ASGIApplication, 32 | client: Optional[TestClient] = None, 33 | **kwargs, 34 | ): 35 | super().__init__(app, *args, **kwargs) 36 | self.asgi = asgi 37 | self.specified_client = client 38 | self.client = TestClient(self.asgi) 39 | 40 | def get_client(self) -> TestClient: 41 | return self.specified_client or self.client 42 | 43 | async def setup(self) -> None: 44 | await super().setup() 45 | if self.specified_client is None: 46 | await self.stack.enter_async_context(self.client) 47 | 48 | 49 | # @final 50 | # class ClientContext(Context): 51 | # def __init__( 52 | # self, 53 | # app: BaseApp, 54 | # *args, 55 | # **kwargs, 56 | # ): 57 | # super().__init__(app, *args, **kwargs) 58 | # self.server = self.app.httpserver 59 | 60 | # def get_server(self) -> HTTPServer: 61 | # return self.server 62 | 63 | # async def run_test(self): 64 | # self.server.clear() 65 | 66 | 67 | class DriverMixin(BaseApp): 68 | def test_server(self, asgi: Optional[ASGIApplication] = None) -> ServerContext: 69 | import nonebot 70 | 71 | client = None 72 | if asgi is None: 73 | client = get_global_client() 74 | 75 | _asgi = asgi or nonebot.get_asgi() 76 | return ServerContext(self, asgi=_asgi, client=client) 77 | 78 | # def test_client(self): 79 | # ... 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | nonebot 5 |

6 | 7 |
8 | 9 | # NoneBug 10 | 11 | 12 | 13 | _✨ NoneBot2 测试框架 ✨_ 14 | 15 | 16 |
17 | 18 |

19 | 20 | license 21 | 22 | 23 | pypi 24 | 25 | python 26 | 27 | 28 | 29 |
30 | 31 | QQ Chat 32 | 33 | 34 | Telegram Channel 35 | 36 | 37 | Discord Server 38 | 39 |

40 | 41 |

42 | 文档 43 |

44 | 45 | ## 安装 46 | 47 | 本工具为 [pytest](https://docs.pytest.org/en/stable/) 插件,需要配合 pytest 异步插件使用。 48 | 49 | ```bash 50 | uv add nonebug pytest-asyncio --group test 51 | # 或者使用 anyio 52 | uv add nonebug anyio --group test 53 | ``` 54 | 55 | ```bash 56 | poetry add nonebug pytest-asyncio -G test 57 | # 或者使用 anyio 58 | poetry add nonebug anyio -G test 59 | ``` 60 | 61 | ```bash 62 | pdm add nonebug pytest-asyncio -dG test 63 | # 或者使用 anyio 64 | pdm add nonebug anyio -dG test 65 | ``` 66 | 67 | ```bash 68 | pip install nonebug pytest-asyncio 69 | # 或者使用 anyio 70 | pip install nonebug anyio 71 | ``` 72 | -------------------------------------------------------------------------------- /tests/test_process.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from nonebug import App 4 | from tests.utils import make_fake_event, make_fake_message 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_process(app: App): 9 | from tests.plugins.process import ( 10 | test, 11 | test_ignore, 12 | test_not_pass_perm, 13 | test_not_pass_rule, 14 | ) 15 | 16 | Message = make_fake_message() 17 | 18 | async with app.test_matcher() as ctx: 19 | adapter = ctx.create_adapter() 20 | bot = ctx.create_bot(adapter=adapter) 21 | 22 | event = make_fake_event(_message=Message())() 23 | ctx.receive_event(bot, event) 24 | 25 | ctx.should_pass_permission(matcher=test) 26 | ctx.should_pass_rule(matcher=test) 27 | ctx.should_not_pass_permission(matcher=test_not_pass_perm) 28 | ctx.should_pass_permission(matcher=test_not_pass_rule) 29 | ctx.should_not_pass_rule(matcher=test_not_pass_rule) 30 | ctx.should_ignore_permission() 31 | ctx.should_not_pass_rule() 32 | 33 | ctx.should_call_send(event, "test_send", "result", bot=bot) 34 | ctx.should_call_api("test", {"key": "value"}, "result", adapter=adapter) 35 | ctx.should_paused(matcher=test) 36 | 37 | event = make_fake_event(_message=Message())() 38 | ctx.receive_event(bot, event) 39 | 40 | ctx.should_pass_permission(matcher=test) 41 | ctx.should_pass_rule(matcher=test) 42 | 43 | ctx.should_rejected(matcher=test) 44 | 45 | async with app.test_matcher(test_ignore) as ctx: 46 | adapter = ctx.create_adapter() 47 | bot = ctx.create_bot(adapter=adapter) 48 | 49 | event = make_fake_event(_message=Message())() 50 | ctx.receive_event(bot, event) 51 | 52 | ctx.should_ignore_permission() 53 | ctx.should_ignore_rule() 54 | 55 | ctx.should_call_send(event, "key", "result", bot=bot) 56 | ctx.should_rejected() 57 | 58 | event = make_fake_event(_message=Message())() 59 | ctx.receive_event(bot, event) 60 | 61 | ctx.should_pass_permission() 62 | ctx.should_pass_rule() 63 | 64 | ctx.should_call_send(event, "message", "result", bot=bot) 65 | ctx.should_finished() 66 | 67 | 68 | @pytest.mark.asyncio 69 | @pytest.mark.xfail(strict=True) 70 | async def test_error(app: App): 71 | from tests.plugins.process import test_error 72 | 73 | async with app.test_matcher(test_error) as ctx: 74 | adapter = ctx.create_adapter() 75 | bot = ctx.create_bot(adapter=adapter) 76 | 77 | event = make_fake_event()() 78 | ctx.receive_event(bot, event) 79 | 80 | ctx.should_call_send(event, "uncorrect", "result", bot=bot) 81 | -------------------------------------------------------------------------------- /nonebug/fixture.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | from async_asgi_testclient import TestClient 4 | import pytest 5 | 6 | from nonebug.app import App 7 | from nonebug.mixin.driver import set_global_client 8 | 9 | from . import NONEBOT_INIT_KWARGS, NONEBOT_START_LIFESPAN 10 | 11 | 12 | @asynccontextmanager 13 | async def nullcontext(): 14 | yield 15 | 16 | 17 | @asynccontextmanager 18 | async def lifespan_ctx(): 19 | import nonebot 20 | from nonebot import logger 21 | from nonebot.drivers import ASGIMixin 22 | 23 | driver = nonebot.get_driver() 24 | 25 | if isinstance(driver, ASGIMixin): 26 | # if the driver has an asgi application 27 | # use asgi lifespan to startup/shutdown 28 | ctx = TestClient(driver.asgi) 29 | set_global_client(ctx) 30 | else: 31 | ctx = driver._lifespan 32 | 33 | try: 34 | await ctx.__aenter__() 35 | except Exception as e: 36 | logger.opt(colors=True, exception=e).error( 37 | "Error occurred while running startup hook." 38 | ) 39 | raise 40 | 41 | try: 42 | yield 43 | finally: 44 | try: 45 | await ctx.__aexit__(None, None, None) 46 | except Exception as e: 47 | logger.opt(colors=True, exception=e).error( 48 | "Error occurred while running shutdown hook." 49 | "" 50 | ) 51 | 52 | 53 | @pytest.fixture(scope="session", autouse=True) 54 | def _nonebot_init(request: pytest.FixtureRequest): 55 | """ 56 | Initialize nonebot before test case running. 57 | """ 58 | import nonebot 59 | from nonebot.matcher import matchers 60 | 61 | from nonebug.provider import NoneBugProvider 62 | 63 | nonebot.init(**request.config.stash.get(NONEBOT_INIT_KWARGS, {})) 64 | matchers.set_provider(NoneBugProvider) 65 | 66 | 67 | @pytest.fixture(scope="session", autouse=True) 68 | async def after_nonebot_init(_nonebot_init: None): 69 | pass 70 | 71 | 72 | @pytest.fixture(scope="session", autouse=True) 73 | async def nonebug_init( 74 | _nonebot_init: None, after_nonebot_init: None, request: pytest.FixtureRequest 75 | ): 76 | run_lifespan = request.config.stash.get(NONEBOT_START_LIFESPAN, True) 77 | 78 | ctx = lifespan_ctx() if run_lifespan else nullcontext() 79 | 80 | async with ctx: 81 | yield 82 | 83 | 84 | @pytest.fixture(name="app") 85 | def nonebug_app(nonebug_init) -> App: 86 | """ 87 | Get a test app provided by nonebug. 88 | Use app to define test cases and run them. 89 | """ 90 | return App() 91 | 92 | 93 | __all__ = ["after_nonebot_init", "nonebug_app", "nonebug_init"] 94 | -------------------------------------------------------------------------------- /nonebug/mixin/process/fake.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable 2 | from typing import TYPE_CHECKING, Callable 3 | from typing_extensions import ParamSpec 4 | 5 | from _pytest.outcomes import OutcomeException 6 | 7 | from .model import Error 8 | 9 | if TYPE_CHECKING: 10 | from nonebot.matcher import Matcher 11 | 12 | from . import MatcherContext 13 | 14 | P = ParamSpec("P") 15 | 16 | 17 | def make_fake_default_state(ctx: "MatcherContext", matcher: type["Matcher"]) -> dict: 18 | return {**matcher._default_state, "__nonebug_matcher__": matcher} 19 | 20 | 21 | def make_fake_check_perm( 22 | ctx: "MatcherContext", matcher: type["Matcher"] 23 | ) -> Callable[..., Awaitable[bool]]: 24 | check_perm = matcher.__dict__["check_perm"] 25 | 26 | @classmethod 27 | async def fake_check_perm(cls: type["Matcher"], *args, **kwargs) -> bool: 28 | result = await check_perm.__get__(None, cls)(*args, **kwargs) 29 | return ctx.got_check_permission( 30 | cls._default_state["__nonebug_matcher__"], result 31 | ) 32 | 33 | return fake_check_perm 34 | 35 | 36 | def make_fake_check_rule( 37 | ctx: "MatcherContext", matcher: type["Matcher"] 38 | ) -> Callable[..., Awaitable[bool]]: 39 | check_rule = matcher.__dict__["check_rule"] 40 | 41 | @classmethod 42 | async def fake_check_rule(cls: type["Matcher"], *args, **kwargs) -> bool: 43 | result = await check_rule.__get__(None, cls)(*args, **kwargs) 44 | return ctx.got_check_rule(cls._default_state["__nonebug_matcher__"], result) 45 | 46 | return fake_check_rule 47 | 48 | 49 | def make_fake_simple_run( 50 | ctx: "MatcherContext", matcher: type["Matcher"] 51 | ) -> Callable[..., Awaitable[None]]: 52 | simple_run = matcher.simple_run 53 | 54 | async def fake_simple_run(self: "Matcher", *args, **kwargs) -> None: 55 | from nonebot.exception import ( 56 | FinishedException, 57 | PausedException, 58 | RejectedException, 59 | ) 60 | 61 | try: 62 | await simple_run(self, *args, **kwargs) 63 | except RejectedException: 64 | ctx.got_action(self._default_state["__nonebug_matcher__"], "reject") 65 | raise 66 | except PausedException: 67 | ctx.got_action(self._default_state["__nonebug_matcher__"], "pause") 68 | raise 69 | except FinishedException: 70 | ctx.got_action(self._default_state["__nonebug_matcher__"], "finish") 71 | raise 72 | 73 | return fake_simple_run 74 | 75 | 76 | def make_fake_run( 77 | ctx: "MatcherContext", matcher: type["Matcher"] 78 | ) -> Callable[..., Awaitable[None]]: 79 | run = matcher.run 80 | 81 | async def fake_run(self: "Matcher", *args, **kwargs) -> None: 82 | try: 83 | await run(self, *args, **kwargs) 84 | except (Exception, OutcomeException) as e: 85 | ctx.errors.append(Error(self.__class__, e)) 86 | raise 87 | 88 | return fake_run 89 | 90 | 91 | PATCHES = { 92 | "check_perm": make_fake_check_perm, 93 | "check_rule": make_fake_check_rule, 94 | "simple_run": make_fake_simple_run, 95 | "run": make_fake_run, 96 | } 97 | -------------------------------------------------------------------------------- /nonebug/provider.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from collections.abc import ( 3 | ItemsView, 4 | Iterator, 5 | KeysView, 6 | Mapping, 7 | MutableMapping, 8 | ValuesView, 9 | ) 10 | from contextlib import contextmanager 11 | from copy import deepcopy 12 | from typing import Optional, TypeVar, Union, overload 13 | 14 | from nonebot.matcher import Matcher, MatcherProvider 15 | 16 | T = TypeVar("T") 17 | 18 | 19 | class NoneBugProvider(MatcherProvider): # pragma: no cover 20 | def __init__(self, matchers: Mapping[int, list[type[Matcher]]]): 21 | self._matchers: dict[int, list[type[Matcher]]] = defaultdict(list, matchers) 22 | 23 | self._stack: list[dict[int, list[type[Matcher]]]] = [] 24 | 25 | def __repr__(self) -> str: 26 | return f"NoneBugProvider(matchers={self._matchers!r})" 27 | 28 | def __contains__(self, o: object) -> bool: 29 | return o in self._matchers 30 | 31 | def __iter__(self) -> Iterator[int]: 32 | return iter(self._matchers) 33 | 34 | def __len__(self) -> int: 35 | return len(self._matchers) 36 | 37 | def __getitem__(self, key: int) -> list[type["Matcher"]]: 38 | return self._matchers[key] 39 | 40 | def __setitem__(self, key: int, value: list[type["Matcher"]]) -> None: 41 | self._matchers[key] = value 42 | 43 | def __delitem__(self, key: int) -> None: 44 | del self._matchers[key] 45 | 46 | def __eq__(self, other: object) -> bool: 47 | return self._matchers == other 48 | 49 | def keys(self) -> KeysView[int]: 50 | return self._matchers.keys() 51 | 52 | def values(self) -> ValuesView[list[type["Matcher"]]]: 53 | return self._matchers.values() 54 | 55 | def items(self) -> ItemsView[int, list[type["Matcher"]]]: 56 | return self._matchers.items() 57 | 58 | @overload 59 | def get(self, key: int) -> Optional[list[type["Matcher"]]]: ... 60 | 61 | @overload 62 | def get(self, key: int, default: T) -> Union[list[type["Matcher"]], T]: ... 63 | 64 | def get( 65 | self, key: int, default: Optional[T] = None 66 | ) -> Optional[Union[list[type["Matcher"]], T]]: 67 | return self._matchers.get(key, default) 68 | 69 | def pop(self, key: int) -> list[type["Matcher"]]: # type: ignore 70 | return self._matchers.pop(key) 71 | 72 | def popitem(self) -> tuple[int, list[type["Matcher"]]]: 73 | return self._matchers.popitem() 74 | 75 | def clear(self) -> None: 76 | self._matchers.clear() 77 | 78 | def update(self, m: MutableMapping[int, list[type["Matcher"]]], /) -> None: # type: ignore 79 | self._matchers.update(m) 80 | 81 | def setdefault( 82 | self, key: int, default: list[type["Matcher"]] 83 | ) -> list[type["Matcher"]]: 84 | return self._matchers.setdefault(key, default) 85 | 86 | @contextmanager 87 | def context(self, matchers: Optional[Mapping[int, list[type[Matcher]]]] = None): 88 | self._stack.append(self._matchers) 89 | self._matchers = ( 90 | deepcopy(self._matchers) 91 | if matchers is None 92 | else defaultdict(list, matchers) 93 | ) 94 | try: 95 | yield self 96 | finally: 97 | self._matchers = self._stack.pop() 98 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebug" 3 | version = "0.4.3" 4 | description = "nonebot2 test framework" 5 | readme = "README.md" 6 | requires-python = ">=3.9, <4.0" 7 | license = "MIT" 8 | authors = [ 9 | { name = "AkiraXie", email = "l997460364@outlook.com" }, 10 | { name = "yanyongyu", email = "yyy@nonebot.dev" }, 11 | ] 12 | keywords = ["bot", "cqhttp", "nonebot", "onebot", "pytest", "test"] 13 | classifiers = ["Framework :: Pytest"] 14 | dependencies = [ 15 | "asgiref >=3.8.0, <4.0.0", 16 | "async-asgi-testclient >=1.4.8, <2.0.0", 17 | "nonebot2 >=2.3.0, <3.0.0", 18 | "pytest >=7.0.0, <10.0.0", 19 | ] 20 | 21 | [project.urls] 22 | documentation = "https://nonebot.dev/" 23 | homepage = "https://nonebot.dev/" 24 | repository = "https://github.com/nonebot/nonebug" 25 | 26 | [project.entry-points.pytest11] 27 | nonebug = "nonebug.fixture" 28 | 29 | [dependency-groups] 30 | dev = [ 31 | "nonemoji >=0.1.4, <1.0.0", 32 | "pre-commit >=4.3.0, <5.0.0", 33 | "ruff >=0.14.7, <0.15.0", 34 | { include-group = "test" }, 35 | ] 36 | test = [ 37 | "nonebot2[fastapi] >=2.3.0, <3.0.0", 38 | "pytest-asyncio >=1.1.0, <2.0.0", 39 | "pytest-cov >=7.0.0, <8.0.0", 40 | ] 41 | 42 | [build-system] 43 | requires = ["uv_build >=0.9.0, <0.10.0"] 44 | build-backend = "uv_build" 45 | 46 | [tool.uv.build-backend] 47 | module-name = "nonebug" 48 | module-root = "" 49 | 50 | [tool.coverage.report] 51 | exclude_lines = [ 52 | "pragma: no cover", 53 | "def __repr__", 54 | "def __str__", 55 | "if __name__ == .__main__.:", 56 | "if (typing\\.)?TYPE_CHECKING( is True)?:", 57 | "@(abc\\.)?abstractmethod", 58 | "@(typing\\.)?overload", 59 | "raise NotImplementedError", 60 | "\\.\\.\\.", 61 | "pass", 62 | ] 63 | 64 | [tool.pyright] 65 | typeCheckingMode = "standard" 66 | reportPrivateImportUsage = false 67 | disableBytesTypePromotions = true 68 | pythonVersion = "3.9" 69 | pythonPlatform = "All" 70 | 71 | [tool.pytest.ini_options] 72 | asyncio_mode = "auto" 73 | addopts = "--cov nonebug --cov-report term-missing -p no:nonebug" 74 | 75 | [tool.ruff] 76 | line-length = 88 77 | 78 | [tool.ruff.format] 79 | line-ending = "lf" 80 | 81 | [tool.ruff.lint] 82 | select = [ 83 | "F", # Pyflakes 84 | "W", # pycodestyle warnings 85 | "E", # pycodestyle errors 86 | "I", # isort 87 | "UP", # pyupgrade 88 | "ASYNC", # flake8-async 89 | "C4", # flake8-comprehensions 90 | "T10", # flake8-debugger 91 | "T20", # flake8-print 92 | "PYI", # flake8-pyi 93 | "PT", # flake8-pytest-style 94 | "Q", # flake8-quotes 95 | "TID", # flake8-tidy-imports 96 | "RUF", # Ruff-specific rules 97 | ] 98 | 99 | ignore = [ 100 | "E402", # module-import-not-at-top-of-file 101 | "UP037", # quoted-annotation 102 | "RUF001", # ambiguous-unicode-character-string 103 | "RUF002", # ambiguous-unicode-character-docstring 104 | "RUF003", # ambiguous-unicode-character-comment 105 | ] 106 | 107 | [tool.ruff.lint.isort] 108 | force-sort-within-sections = true 109 | extra-standard-library = ["typing_extensions"] 110 | 111 | [tool.ruff.lint.flake8-pytest-style] 112 | fixture-parentheses = false 113 | mark-parentheses = false 114 | 115 | [tool.ruff.lint.pyupgrade] 116 | keep-runtime-typing = true 117 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from typing import Optional, Union 3 | 4 | from nonebot.adapters import Event, Message, MessageSegment 5 | from pydantic import create_model 6 | 7 | 8 | def escape_text(s: str, *, escape_comma: bool = True) -> str: 9 | s = s.replace("&", "&").replace("[", "[").replace("]", "]") 10 | if escape_comma: 11 | s = s.replace(",", ",") 12 | return s 13 | 14 | 15 | def make_fake_message(): 16 | class FakeMessageSegment(MessageSegment): 17 | @classmethod 18 | def get_message_class(cls): 19 | return FakeMessage 20 | 21 | def __str__(self) -> str: 22 | return self.data["text"] if self.type == "text" else f"[fake:{self.type}]" 23 | 24 | @classmethod 25 | def text(cls, text: str): 26 | return cls("text", {"text": text}) 27 | 28 | @staticmethod 29 | def image(url: str): 30 | return FakeMessageSegment("image", {"url": url}) 31 | 32 | @staticmethod 33 | def nested(content: "FakeMessage"): 34 | return FakeMessageSegment("node", {"content": content}) 35 | 36 | def is_text(self) -> bool: 37 | return self.type == "text" 38 | 39 | class FakeMessage(Message): 40 | @classmethod 41 | def get_segment_class(cls): 42 | return FakeMessageSegment 43 | 44 | @staticmethod 45 | def _construct(msg: Union[str, Iterable[Mapping]]): 46 | if isinstance(msg, str): 47 | yield FakeMessageSegment.text(msg) 48 | else: 49 | for seg in msg: 50 | yield FakeMessageSegment(**seg) 51 | return 52 | 53 | def __add__(self, other): 54 | other = escape_text(other) if isinstance(other, str) else other 55 | return super().__add__(other) 56 | 57 | return FakeMessage 58 | 59 | 60 | def make_fake_event( 61 | _base: Optional[type[Event]] = None, 62 | _type: str = "message", 63 | _name: str = "test", 64 | _description: str = "test", 65 | _user_id: Optional[str] = "test", 66 | _session_id: Optional[str] = "test", 67 | _message: Optional[Message] = None, 68 | _to_me: bool = True, 69 | **fields, 70 | ) -> type[Event]: 71 | _Fake = create_model("_Fake", __base__=_base or Event, **fields) 72 | 73 | class FakeEvent(_Fake): 74 | model_config = {"extra": "forbid"} # noqa: RUF012 75 | 76 | def get_type(self) -> str: 77 | return _type 78 | 79 | def get_event_name(self) -> str: 80 | return _name 81 | 82 | def get_event_description(self) -> str: 83 | return _description 84 | 85 | def get_user_id(self) -> str: 86 | if _user_id is not None: 87 | return _user_id 88 | raise NotImplementedError 89 | 90 | def get_session_id(self) -> str: 91 | if _session_id is not None: 92 | return _session_id 93 | raise NotImplementedError 94 | 95 | def get_message(self) -> "Message": 96 | if _message is not None: 97 | return _message 98 | raise NotImplementedError 99 | 100 | def is_tome(self) -> bool: 101 | return _to_me 102 | 103 | return FakeEvent 104 | -------------------------------------------------------------------------------- /tests/test_call_api.py: -------------------------------------------------------------------------------- 1 | from nonebot import get_bot, get_bots 2 | from nonebot.adapters import Adapter, Bot 3 | import pytest 4 | 5 | from nonebug import App 6 | from tests.utils import make_fake_event 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_should_call_api(app: App): 11 | async with app.test_api() as ctx: 12 | api = ctx.should_call_api("test", {"data": "data"}, "result") 13 | queue = ctx.wait_list 14 | assert not queue.empty() 15 | assert api == queue.get() 16 | assert api.name == "test" 17 | assert api.data == {"data": "data"} 18 | assert api.result == "result" 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_should_call_send(app: App): 23 | from nonebot.adapters import Event, Message 24 | 25 | class FakeEvent(Event): 26 | model_config = {"extra": "forbid"} # noqa: RUF012 27 | 28 | def get_type(self) -> str: 29 | return "test" 30 | 31 | def get_event_name(self) -> str: 32 | return "test" 33 | 34 | def get_event_description(self) -> str: 35 | return "test" 36 | 37 | def get_user_id(self) -> str: 38 | return "test" 39 | 40 | def get_session_id(self) -> str: 41 | return "test" 42 | 43 | def get_message(self) -> Message: 44 | raise NotImplementedError 45 | 46 | def is_tome(self) -> bool: 47 | return True 48 | 49 | event = FakeEvent() 50 | async with app.test_api() as ctx: 51 | send = ctx.should_call_send(event, "test message", "result") 52 | queue = ctx.wait_list 53 | assert not queue.empty() 54 | assert send == queue.get() 55 | assert send.event is event 56 | assert send.message == "test message" 57 | assert send.result == "result" 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_fake(app: App): 62 | class FakeAdapter(Adapter): ... 63 | 64 | class FakeBot(Bot): ... 65 | 66 | async with app.test_api() as ctx: 67 | adapter = ctx.create_adapter(base=FakeAdapter) 68 | assert isinstance(adapter, FakeAdapter) 69 | assert adapter.get_name() == "fake" 70 | bot = ctx.create_bot(base=FakeBot, self_id="test", adapter=adapter) 71 | assert isinstance(bot, FakeBot) 72 | assert bot.self_id == "test" 73 | assert adapter.bots[bot.self_id] is bot 74 | assert get_bot(bot.self_id) is bot 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_got_call_api(app: App): 79 | async with app.test_api() as ctx: 80 | adapter = ctx.create_adapter() 81 | bot = ctx.create_bot(self_id="test", adapter=adapter) 82 | assert "test" in get_bots() 83 | ctx.should_call_api("test", {"key": "value"}, "result", adapter=adapter) 84 | result = await bot.call_api("test", key="value") 85 | assert ctx.wait_list.empty() 86 | assert result == "result" 87 | 88 | assert "test" not in get_bots() 89 | 90 | async with app.test_api() as ctx: 91 | adapter = ctx.create_adapter() 92 | bot = ctx.create_bot(self_id="test", adapter=adapter) 93 | assert "test" in get_bots() 94 | ctx.should_call_api( 95 | "test", {"key": "value"}, exception=RuntimeError(), adapter=adapter 96 | ) 97 | with pytest.raises(RuntimeError): 98 | result = await bot.call_api("test", key="value") 99 | 100 | assert ctx.wait_list.empty() 101 | 102 | assert "test" not in get_bots() 103 | 104 | 105 | @pytest.mark.asyncio 106 | async def test_got_call_send(app: App): 107 | async with app.test_api() as ctx: 108 | bot = ctx.create_bot(self_id="test") 109 | assert "test" in get_bots() 110 | event = make_fake_event()() 111 | ctx.should_call_send(event, "test", "result", bot=bot, key="value") 112 | result = await bot.send(event, "test", key="value") 113 | assert ctx.wait_list.empty() 114 | assert result == "result" 115 | 116 | assert "test" not in get_bots() 117 | 118 | async with app.test_api() as ctx: 119 | bot = ctx.create_bot(self_id="test") 120 | assert "test" in get_bots() 121 | event = make_fake_event()() 122 | ctx.should_call_send( 123 | event, "test", exception=RuntimeError(), bot=bot, key="value" 124 | ) 125 | with pytest.raises(RuntimeError): 126 | result = await bot.send(event, "test", key="value") 127 | 128 | assert ctx.wait_list.empty() 129 | 130 | assert "test" not in get_bots() 131 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,macos,windows,linux 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | # Thumbnails 29 | ._* 30 | 31 | # Files that might appear in the root of a volume 32 | .DocumentRevisions-V100 33 | .fseventsd 34 | .Spotlight-V100 35 | .TemporaryItems 36 | .Trashes 37 | .VolumeIcon.icns 38 | .com.apple.timemachine.donotpresent 39 | 40 | # Directories potentially created on remote AFP share 41 | .AppleDB 42 | .AppleDesktop 43 | Network Trash Folder 44 | Temporary Items 45 | .apdisk 46 | 47 | ### Python ### 48 | # Byte-compiled / optimized / DLL files 49 | __pycache__/ 50 | *.py[cod] 51 | *$py.class 52 | 53 | # C extensions 54 | *.so 55 | 56 | # Distribution / packaging 57 | .Python 58 | build/ 59 | develop-eggs/ 60 | dist/ 61 | downloads/ 62 | eggs/ 63 | .eggs/ 64 | lib/ 65 | lib64/ 66 | parts/ 67 | sdist/ 68 | var/ 69 | wheels/ 70 | share/python-wheels/ 71 | *.egg-info/ 72 | .installed.cfg 73 | *.egg 74 | MANIFEST 75 | 76 | # PyInstaller 77 | # Usually these files are written by a python script from a template 78 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 79 | *.manifest 80 | *.spec 81 | 82 | # Installer logs 83 | pip-log.txt 84 | pip-delete-this-directory.txt 85 | 86 | # Unit test / coverage reports 87 | htmlcov/ 88 | .tox/ 89 | .nox/ 90 | .coverage 91 | .coverage.* 92 | .cache 93 | nosetests.xml 94 | coverage.xml 95 | *.cover 96 | *.py,cover 97 | .hypothesis/ 98 | .pytest_cache/ 99 | cover/ 100 | 101 | # Translations 102 | *.mo 103 | *.pot 104 | 105 | # Django stuff: 106 | *.log 107 | local_settings.py 108 | db.sqlite3 109 | db.sqlite3-journal 110 | 111 | # Flask stuff: 112 | instance/ 113 | .webassets-cache 114 | 115 | # Scrapy stuff: 116 | .scrapy 117 | 118 | # Sphinx documentation 119 | docs/_build/ 120 | 121 | # PyBuilder 122 | .pybuilder/ 123 | target/ 124 | 125 | # Jupyter Notebook 126 | .ipynb_checkpoints 127 | 128 | # IPython 129 | profile_default/ 130 | ipython_config.py 131 | 132 | # pyenv 133 | # For a library or package, you might want to ignore these files since the code is 134 | # intended to run in multiple environments; otherwise, check them in: 135 | # .python-version 136 | 137 | # pipenv 138 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 139 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 140 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 141 | # install all needed dependencies. 142 | #Pipfile.lock 143 | 144 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 145 | __pypackages__/ 146 | 147 | # Celery stuff 148 | celerybeat-schedule 149 | celerybeat.pid 150 | 151 | # SageMath parsed files 152 | *.sage.py 153 | 154 | # Environments 155 | .env 156 | .venv 157 | env/ 158 | venv/ 159 | ENV/ 160 | env.bak/ 161 | venv.bak/ 162 | 163 | # Spyder project settings 164 | .spyderproject 165 | .spyproject 166 | 167 | # Rope project settings 168 | .ropeproject 169 | 170 | # mkdocs documentation 171 | /site 172 | 173 | # mypy 174 | .mypy_cache/ 175 | .dmypy.json 176 | dmypy.json 177 | 178 | # Pyre type checker 179 | .pyre/ 180 | 181 | # pytype static type analyzer 182 | .pytype/ 183 | 184 | # Cython debug symbols 185 | cython_debug/ 186 | 187 | ### VisualStudioCode ### 188 | .vscode/* 189 | # !.vscode/settings.json 190 | # !.vscode/tasks.json 191 | # !.vscode/launch.json 192 | # !.vscode/extensions.json 193 | *.code-workspace 194 | 195 | # Local History for Visual Studio Code 196 | .history/ 197 | 198 | ### VisualStudioCode Patch ### 199 | # Ignore all local history of files 200 | .history 201 | .ionide 202 | 203 | # Support for Project snippet scope 204 | !.vscode/*.code-snippets 205 | 206 | ### Windows ### 207 | # Windows thumbnail cache files 208 | Thumbs.db 209 | Thumbs.db:encryptable 210 | ehthumbs.db 211 | ehthumbs_vista.db 212 | 213 | # Dump file 214 | *.stackdump 215 | 216 | # Folder config file 217 | [Dd]esktop.ini 218 | 219 | # Recycle Bin used on file shares 220 | $RECYCLE.BIN/ 221 | 222 | # Windows Installer files 223 | *.cab 224 | *.msi 225 | *.msix 226 | *.msm 227 | *.msp 228 | 229 | # Windows shortcuts 230 | *.lnk 231 | 232 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux 233 | -------------------------------------------------------------------------------- /nonebug/mixin/call_api/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from queue import Queue 3 | from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, overload 4 | 5 | import pytest 6 | 7 | from nonebug.base import BaseApp, Context 8 | 9 | from .fake import make_fake_adapter, make_fake_bot 10 | from .model import Api, Model, Send 11 | 12 | if TYPE_CHECKING: 13 | from nonebot.adapters import Adapter, Bot, Event, Message, MessageSegment 14 | 15 | A = TypeVar("A", bound="Adapter") 16 | B = TypeVar("B", bound="Bot") 17 | 18 | 19 | class ApiContext(Context): 20 | """API testing context. 21 | 22 | This context is used to test the api calling behavior of the bot. 23 | You may inherit this class to make api testing available in other context. 24 | 25 | Note: 26 | API testing needs to create new bots from `ApiContext.create_bot` or 27 | patch existing bots with `ApiContext.patch_bot`. 28 | 29 | Bots created from `ApiContext.create_bot` will be automatically connected 30 | to nonebot driver, and disconnected when the context is exited. 31 | """ 32 | 33 | def __init__(self, app: BaseApp, *args, **kwargs): 34 | super().__init__(app, *args, **kwargs) 35 | self.wait_list: Queue[Model] = Queue() 36 | self.connected_bot: set["Bot"] = set() 37 | 38 | def _connect_bot(self, adapter: "Adapter", bot: "Bot") -> None: 39 | adapter.bot_connect(bot) 40 | self.connected_bot.add(bot) 41 | 42 | @overload 43 | def create_adapter( 44 | self, 45 | *, 46 | base: None = None, 47 | **kwargs: Any, 48 | ) -> "Adapter": ... 49 | 50 | @overload 51 | def create_adapter( 52 | self, 53 | *, 54 | base: Optional[type[A]] = None, 55 | **kwargs: Any, 56 | ) -> A: ... 57 | 58 | def create_adapter( 59 | self, 60 | *, 61 | base: Optional[type[A]] = None, 62 | **kwargs: Any, 63 | ) -> Union[A, "Adapter"]: 64 | from nonebot import get_driver 65 | 66 | return make_fake_adapter(self, base=base)(get_driver(), **kwargs) 67 | 68 | @overload 69 | def create_bot( 70 | self, 71 | *, 72 | base: None = None, 73 | adapter: Optional["Adapter"] = None, 74 | self_id: str = "test", 75 | auto_connect: bool = True, 76 | **kwargs: Any, 77 | ) -> "Bot": ... 78 | 79 | @overload 80 | def create_bot( 81 | self, 82 | *, 83 | base: Optional[type[B]] = None, 84 | adapter: Optional["Adapter"] = None, 85 | self_id: str = "test", 86 | auto_connect: bool = True, 87 | **kwargs: Any, 88 | ) -> B: ... 89 | 90 | def create_bot( 91 | self, 92 | *, 93 | base: Optional[type[B]] = None, 94 | adapter: Optional["Adapter"] = None, 95 | self_id: str = "test", 96 | auto_connect: bool = True, 97 | **kwargs: Any, 98 | ) -> Union[B, "Bot"]: 99 | adapter = adapter or self.create_adapter() 100 | bot = make_fake_bot(self, base=base)(adapter, self_id, **kwargs) 101 | if auto_connect: 102 | self._connect_bot(adapter, bot) 103 | return bot 104 | 105 | def patch_adapter( 106 | self, monkeypatch: pytest.MonkeyPatch, adapter: "Adapter" 107 | ) -> None: 108 | new_adapter = self.create_adapter() 109 | monkeypatch.setattr(adapter, "_call_api", getattr(new_adapter, "_call_api")) 110 | 111 | def patch_bot(self, monkeypatch: pytest.MonkeyPatch, bot: "Bot") -> None: 112 | new_bot = self.create_bot(auto_connect=False) 113 | monkeypatch.setattr(bot, "send", getattr(new_bot, "send")) 114 | 115 | def should_call_api( 116 | self, 117 | api: str, 118 | data: dict[str, Any], 119 | result: Optional[Any] = None, 120 | exception: Optional[Exception] = None, 121 | adapter: Optional["Adapter"] = None, 122 | ) -> Api: 123 | model = Api( 124 | name=api, data=data, result=result, exception=exception, adapter=adapter 125 | ) 126 | self.wait_list.put(model) 127 | return model 128 | 129 | def should_call_send( 130 | self, 131 | event: "Event", 132 | message: Union[str, "Message", "MessageSegment"], 133 | result: Optional[Any] = None, 134 | exception: Optional[Exception] = None, 135 | bot: Optional["Bot"] = None, 136 | **kwargs: Any, 137 | ) -> Send: 138 | model = Send( 139 | event=event, 140 | message=message, 141 | kwargs=kwargs, 142 | result=result, 143 | exception=exception, 144 | bot=bot, 145 | ) 146 | self.wait_list.put(model) 147 | return model 148 | 149 | def got_call_api(self, adapter: "Adapter", api: str, **data: Any) -> Any: 150 | if self.wait_list.empty(): 151 | pytest.fail( 152 | f"Application has no api call but expected api={api} data={data}" 153 | ) 154 | model = self.wait_list.get() 155 | if not isinstance(model, Api): 156 | pytest.fail(f"Application got api call {api} but expected {model}") 157 | if model.name != api: 158 | pytest.fail(f"Application got api call {api} but expected {model.name}") 159 | if model.data != data: 160 | pytest.fail( 161 | f"Application got api call {api} with " 162 | f"data {data} but expected {model.data}" 163 | ) 164 | if model.adapter and model.adapter != adapter: 165 | pytest.fail( 166 | f"Application got api call {api} with " 167 | f"adapter {adapter} but expected {model.adapter}" 168 | ) 169 | 170 | if model.exception is not None: 171 | raise model.exception 172 | return model.result 173 | 174 | def got_call_send( 175 | self, 176 | bot: "Bot", 177 | event: "Event", 178 | message: Union[str, "Message", "MessageSegment"], 179 | **kwargs: Any, 180 | ) -> Any: 181 | from nonebot.compat import model_dump 182 | 183 | if self.wait_list.empty(): 184 | pytest.fail( 185 | "Application has no send call but expected " 186 | f"event={event} message={message} kwargs={kwargs}" 187 | ) 188 | model = self.wait_list.get() 189 | if not isinstance(model, Send): 190 | pytest.fail(f"Application got send call but expected {model}") 191 | if model_dump(model.event) != model_dump(event): 192 | pytest.fail( 193 | "Application got send call with " 194 | f"event {event} but expected {model.event}" 195 | ) 196 | if model.message != message: 197 | pytest.fail( 198 | "Application got send call with " 199 | f"message {message} but expected {model.message}" 200 | ) 201 | if model.kwargs != kwargs: 202 | pytest.fail( 203 | "Application got send call with " 204 | f"kwargs {kwargs} but expected {model.kwargs}" 205 | ) 206 | if model.bot and model.bot != bot: 207 | pytest.fail( 208 | f"Application got send call with bot {bot} but expected {model.bot}" 209 | ) 210 | 211 | if model.exception is not None: 212 | raise model.exception 213 | return model.result 214 | 215 | @contextlib.contextmanager 216 | def _prepare_api_context(self): 217 | with pytest.MonkeyPatch.context() as m: 218 | self._prepare_adapters(m) 219 | self._prepare_bots(m) 220 | try: 221 | yield 222 | finally: 223 | while self.connected_bot: 224 | bot = self.connected_bot.pop() 225 | bot.adapter.bot_disconnect(bot) 226 | 227 | def _prepare_adapters(self, monkeypatch: pytest.MonkeyPatch) -> None: 228 | from nonebot import get_driver 229 | 230 | for adapter in get_driver()._adapters.values(): 231 | self.patch_adapter(monkeypatch, adapter) 232 | 233 | def _prepare_bots(self, monkeypatch: pytest.MonkeyPatch) -> None: 234 | from nonebot import get_bots 235 | 236 | for bot in get_bots().values(): 237 | self.patch_bot(monkeypatch, bot) 238 | 239 | async def setup(self): 240 | await super().setup() 241 | self.stack.enter_context(self._prepare_api_context()) 242 | 243 | async def run(self) -> None: 244 | await super().run() 245 | if not self.wait_list.empty(): 246 | pytest.fail( 247 | f"Application has {self.wait_list.qsize()} api/send call(s) not called" 248 | ) 249 | 250 | 251 | class CallApiMixin(BaseApp): 252 | def test_api(self) -> ApiContext: 253 | return ApiContext(self) 254 | -------------------------------------------------------------------------------- /nonebug/mixin/process/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from contextlib import contextmanager 3 | from contextvars import ContextVar 4 | from typing import TYPE_CHECKING, Literal, Optional, TypedDict, Union 5 | from typing_extensions import final 6 | 7 | from _pytest.outcomes import Skipped 8 | import pytest 9 | 10 | from nonebug.base import BaseApp 11 | from nonebug.mixin.call_api import ApiContext 12 | 13 | from .fake import PATCHES, make_fake_default_state 14 | from .model import ( 15 | Action, 16 | Check, 17 | Finished, 18 | IgnorePermission, 19 | IgnoreRule, 20 | Paused, 21 | PermissionNotPass, 22 | PermissionPass, 23 | ReceiveEvent, 24 | Rejected, 25 | RuleNotPass, 26 | RulePass, 27 | ) 28 | 29 | if TYPE_CHECKING: 30 | from nonebot.adapters import Bot, Event 31 | from nonebot.matcher import Matcher 32 | 33 | event_test_context: ContextVar[tuple[ReceiveEvent, "EventTest"]] = ContextVar( 34 | "event_test_context" 35 | ) 36 | 37 | 38 | class EventTest(TypedDict): 39 | checks: list[Check] 40 | actions: list[Action] 41 | 42 | 43 | @final 44 | class MatcherContext(ApiContext): 45 | """Matcher testing context. 46 | 47 | This context is used to test the behavior of matcher(s). 48 | You can give specific matchers to test, or test all available matchers. 49 | 50 | Note: 51 | API testing is also available in this context. 52 | 53 | The matcher behavior should be defined immediately 54 | after the `MatcherContext.receive_event` call. 55 | """ 56 | 57 | def __init__( 58 | self, 59 | app: "ProcessMixin", 60 | *args, 61 | matchers: Optional[dict[int, list[type["Matcher"]]]], 62 | **kwargs, 63 | ): 64 | super().__init__(app, *args, **kwargs) 65 | self.matchers = matchers 66 | self.event_list: list[tuple[ReceiveEvent, EventTest]] = [] 67 | self.errors = [] 68 | 69 | @property 70 | def currect_event_test(self) -> EventTest: 71 | if not self.event_list: 72 | raise RuntimeError("Please call receive_event first") 73 | return self.event_list[-1][1] 74 | 75 | def receive_event(self, bot: "Bot", event: "Event") -> ReceiveEvent: 76 | receive_event = ReceiveEvent(bot, event) 77 | self.event_list.append((receive_event, EventTest(checks=[], actions=[]))) 78 | return receive_event 79 | 80 | def should_pass_rule(self, matcher: Optional[type["Matcher"]] = None) -> RulePass: 81 | rule = RulePass(matcher=matcher) 82 | self.currect_event_test["checks"].append(rule) 83 | return rule 84 | 85 | def should_not_pass_rule( 86 | self, matcher: Optional[type["Matcher"]] = None 87 | ) -> RuleNotPass: 88 | rule = RuleNotPass(matcher=matcher) 89 | self.currect_event_test["checks"].append(rule) 90 | return rule 91 | 92 | def should_ignore_rule( 93 | self, matcher: Optional[type["Matcher"]] = None 94 | ) -> IgnoreRule: 95 | rule = IgnoreRule(matcher=matcher) 96 | self.currect_event_test["checks"].append(rule) 97 | return rule 98 | 99 | def got_check_rule(self, matcher: type["Matcher"], result: bool) -> bool: 100 | context = event_test_context.get() 101 | event = context[0] 102 | checks = [ 103 | c 104 | for c in context[1]["checks"] 105 | if isinstance(c, (RulePass, RuleNotPass, IgnoreRule)) 106 | ] 107 | for check in checks: 108 | if check.matcher is matcher or check.matcher is None: 109 | if isinstance(check, RulePass): 110 | if not result: 111 | pytest.fail( 112 | f"{matcher} should pass rule check when receive {event}" 113 | ) 114 | elif isinstance(check, RuleNotPass): 115 | if result: 116 | pytest.fail( 117 | f"{matcher} should not pass rule check when receive {event}" 118 | ) 119 | elif isinstance(check, IgnoreRule): 120 | result = True 121 | 122 | if check.matcher is matcher: 123 | context[1]["checks"].remove(check) 124 | break 125 | return result 126 | 127 | def should_pass_permission( 128 | self, matcher: Optional[type["Matcher"]] = None 129 | ) -> PermissionPass: 130 | permission = PermissionPass(matcher=matcher) 131 | self.currect_event_test["checks"].append(permission) 132 | return permission 133 | 134 | def should_not_pass_permission( 135 | self, matcher: Optional[type["Matcher"]] = None 136 | ) -> PermissionNotPass: 137 | permission = PermissionNotPass(matcher=matcher) 138 | self.currect_event_test["checks"].append(permission) 139 | return permission 140 | 141 | def should_ignore_permission( 142 | self, matcher: Optional[type["Matcher"]] = None 143 | ) -> IgnorePermission: 144 | permission = IgnorePermission(matcher=matcher) 145 | self.currect_event_test["checks"].append(permission) 146 | return permission 147 | 148 | def got_check_permission(self, matcher: type["Matcher"], result: bool) -> bool: 149 | context = event_test_context.get() 150 | event = context[0] 151 | checks = [ 152 | c 153 | for c in context[1]["checks"] 154 | if isinstance(c, (PermissionPass, PermissionNotPass, IgnorePermission)) 155 | ] 156 | for check in checks: 157 | if check.matcher is matcher or check.matcher is None: 158 | if isinstance(check, PermissionPass): 159 | if not result: 160 | pytest.fail( 161 | f"{matcher} should pass permission check " 162 | f"when receive {event}" 163 | ) 164 | elif isinstance(check, PermissionNotPass): 165 | if result: 166 | pytest.fail( 167 | f"{matcher} should not pass permission check " 168 | f"when receive {event}" 169 | ) 170 | elif isinstance(check, IgnorePermission): 171 | result = True 172 | 173 | if check.matcher is matcher: 174 | context[1]["checks"].remove(check) 175 | break 176 | return result 177 | 178 | def should_paused(self, matcher: Optional[type["Matcher"]] = None) -> Paused: 179 | if any( 180 | action.matcher is matcher for action in self.currect_event_test["actions"] 181 | ): 182 | pytest.fail(f"Should not set action twice for same matcher: {matcher}") 183 | paused = Paused(matcher=matcher) 184 | self.currect_event_test["actions"].append(paused) 185 | return paused 186 | 187 | def should_rejected(self, matcher: Optional[type["Matcher"]] = None) -> Rejected: 188 | if any( 189 | action.matcher is matcher for action in self.currect_event_test["actions"] 190 | ): 191 | pytest.fail(f"Should not set action twice for same matcher: {matcher}") 192 | rejected = Rejected(matcher=matcher) 193 | self.currect_event_test["actions"].append(rejected) 194 | return rejected 195 | 196 | def should_finished(self, matcher: Optional[type["Matcher"]] = None) -> Finished: 197 | if any( 198 | action.matcher is matcher for action in self.currect_event_test["actions"] 199 | ): 200 | pytest.fail(f"Should not set action twice for same matcher: {matcher}") 201 | finished = Finished(matcher=matcher) 202 | self.currect_event_test["actions"].append(finished) 203 | return finished 204 | 205 | def got_action( 206 | self, matcher: type["Matcher"], action: Literal["pause", "reject", "finish"] 207 | ): 208 | context = event_test_context.get() 209 | event = context[0] 210 | actions = context[1]["actions"] 211 | for act in actions: 212 | if act.matcher is matcher or act.matcher is None: 213 | if isinstance(act, Paused): 214 | if action != "pause": 215 | pytest.fail(f"{matcher} should pause when receive {event}") 216 | elif isinstance(act, Rejected): 217 | if action != "reject": 218 | pytest.fail(f"{matcher} should reject when receive {event}") 219 | elif isinstance(act, Finished): 220 | if action != "finish": 221 | pytest.fail(f"{matcher} should finish when receive {event}") 222 | 223 | if act.matcher is matcher: 224 | context[1]["actions"].remove(act) 225 | break 226 | 227 | @contextmanager 228 | def _prepare_matcher_context(self): 229 | from nonebot.matcher import Matcher 230 | 231 | with self.app.provider.context(self.matchers) as provider: 232 | with pytest.MonkeyPatch.context() as m: 233 | self.patch_matcher(m, Matcher) 234 | for matchers in provider.values(): 235 | for matcher in matchers: 236 | m.setattr( 237 | matcher, 238 | "_default_state", 239 | make_fake_default_state(self, matcher), 240 | ) 241 | yield 242 | 243 | def patch_matcher(self, monkeypatch: pytest.MonkeyPatch, matcher: type["Matcher"]): 244 | for attr, patch_func in PATCHES.items(): 245 | monkeypatch.setattr(matcher, attr, patch_func(self, matcher)) 246 | 247 | async def setup(self) -> None: 248 | await super().setup() 249 | self.stack.enter_context(self._prepare_matcher_context()) 250 | 251 | async def run(self): 252 | from nonebot.message import handle_event 253 | 254 | while self.event_list: 255 | event, context = self.event_list.pop(0) 256 | context["checks"].sort(key=lambda x: x.priority) 257 | context["actions"].sort(key=lambda x: x.matcher is None) 258 | t = event_test_context.set((event, context)) 259 | try: 260 | await handle_event(bot=event.bot, event=event.event) 261 | 262 | if self.errors: 263 | if any(isinstance(e, Skipped) for e in self.errors): 264 | pytest.skip( 265 | f"Check skipped when handling event {event}: {self.errors}" 266 | ) 267 | pytest.fail( 268 | f"Some checks failed when handling event {event}: {self.errors}" 269 | ) 270 | if remain_checks := [c for c in context["checks"] if c.matcher]: 271 | pytest.fail( 272 | f"Some checks remain after receive " 273 | f"event {event}: {remain_checks}" 274 | ) 275 | if remain_actions := [a for a in context["actions"] if a.matcher]: 276 | pytest.fail( 277 | f"Some actions remain after receive " 278 | f"event {event}: {remain_actions}" 279 | ) 280 | finally: 281 | self.errors.clear() 282 | event_test_context.reset(t) 283 | 284 | await super().run() 285 | 286 | 287 | class ProcessMixin(BaseApp): 288 | def test_matcher( 289 | self, 290 | m: Union[ 291 | None, 292 | type["Matcher"], 293 | list[type["Matcher"]], 294 | dict[int, list[type["Matcher"]]], 295 | ] = None, 296 | /, 297 | ) -> MatcherContext: 298 | matchers: Optional[dict[int, list[type["Matcher"]]]] 299 | if m is None: 300 | matchers = None 301 | elif isinstance(m, list): 302 | matchers = defaultdict(list) 303 | for matcher in m: 304 | matchers[matcher.priority].append(matcher) 305 | elif isinstance(m, dict): 306 | matchers = m 307 | else: 308 | matchers = {m.priority: [m]} 309 | return MatcherContext(self, matchers=matchers) 310 | --------------------------------------------------------------------------------