mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
feat: 添加对 astrbot_sdk 第一层模块的测试,确保模块可导入及公共 API 正确导出
This commit is contained in:
276
tests_v4/test_top_level_modules.py
Normal file
276
tests_v4/test_top_level_modules.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Tests for first-layer modules under astrbot_sdk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import astrbot_sdk
|
||||
import astrbot_sdk.compat as compat_module
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from astrbot_sdk._legacy_api import (
|
||||
CommandComponent,
|
||||
LegacyContext,
|
||||
LegacyConversationManager,
|
||||
)
|
||||
from astrbot_sdk.cli import cli, setup_logger
|
||||
from astrbot_sdk.context import Context
|
||||
from astrbot_sdk.decorators import on_command
|
||||
from astrbot_sdk.errors import AstrBotError
|
||||
from astrbot_sdk.events import MessageEvent
|
||||
from astrbot_sdk.star import Star
|
||||
|
||||
TOP_LEVEL_MODULES = [
|
||||
"astrbot_sdk",
|
||||
"astrbot_sdk._legacy_api",
|
||||
"astrbot_sdk.cli",
|
||||
"astrbot_sdk.compat",
|
||||
"astrbot_sdk.context",
|
||||
"astrbot_sdk.decorators",
|
||||
"astrbot_sdk.errors",
|
||||
"astrbot_sdk.events",
|
||||
"astrbot_sdk.star",
|
||||
]
|
||||
|
||||
|
||||
class TestTopLevelImports:
|
||||
"""Tests for package first-layer module imports and exports."""
|
||||
|
||||
@pytest.mark.parametrize("module_name", TOP_LEVEL_MODULES)
|
||||
def test_first_layer_modules_are_importable(self, module_name: str):
|
||||
"""All first-layer modules should be importable directly."""
|
||||
assert importlib.import_module(module_name) is not None
|
||||
|
||||
def test_package_reexports_expected_symbols(self):
|
||||
"""astrbot_sdk package should re-export the public API surface."""
|
||||
assert astrbot_sdk.AstrBotError is AstrBotError
|
||||
assert astrbot_sdk.Context is Context
|
||||
assert astrbot_sdk.MessageEvent is MessageEvent
|
||||
assert astrbot_sdk.Star is Star
|
||||
assert astrbot_sdk.on_command is not None
|
||||
assert astrbot_sdk.on_event is not None
|
||||
assert astrbot_sdk.on_message is not None
|
||||
assert astrbot_sdk.on_schedule is not None
|
||||
assert astrbot_sdk.require_admin is not None
|
||||
|
||||
def test_package_all_matches_public_exports(self):
|
||||
"""astrbot_sdk.__all__ should stay aligned with top-level exports."""
|
||||
assert astrbot_sdk.__all__ == [
|
||||
"AstrBotError",
|
||||
"Context",
|
||||
"MessageEvent",
|
||||
"Star",
|
||||
"on_command",
|
||||
"on_event",
|
||||
"on_message",
|
||||
"on_schedule",
|
||||
"require_admin",
|
||||
]
|
||||
|
||||
def test_compat_module_reexports_legacy_symbols(self):
|
||||
"""compat module should proxy legacy compatibility types."""
|
||||
assert compat_module.CommandComponent is CommandComponent
|
||||
assert compat_module.Context is LegacyContext
|
||||
assert compat_module.LegacyContext is LegacyContext
|
||||
assert compat_module.LegacyConversationManager is LegacyConversationManager
|
||||
assert compat_module.__all__ == [
|
||||
"CommandComponent",
|
||||
"Context",
|
||||
"LegacyContext",
|
||||
"LegacyConversationManager",
|
||||
]
|
||||
|
||||
|
||||
class TestCliModule:
|
||||
"""Tests for cli.py and __main__.py."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("verbose", "expected_level"),
|
||||
[
|
||||
(False, "INFO"),
|
||||
(True, "DEBUG"),
|
||||
],
|
||||
)
|
||||
def test_setup_logger_configures_level(self, verbose: bool, expected_level: str):
|
||||
"""setup_logger() should rebuild loguru handlers with the expected level."""
|
||||
mock_logger = Mock()
|
||||
|
||||
with patch("astrbot_sdk.cli.logger", mock_logger):
|
||||
setup_logger(verbose=verbose)
|
||||
|
||||
mock_logger.remove.assert_called_once_with()
|
||||
mock_logger.add.assert_called_once()
|
||||
assert mock_logger.add.call_args.args[0] is sys.stderr
|
||||
assert mock_logger.add.call_args.kwargs["level"] == expected_level
|
||||
assert mock_logger.add.call_args.kwargs["colorize"] is True
|
||||
|
||||
def test_cli_group_sets_up_logging_from_verbose_flag(self):
|
||||
"""cli group should pass the verbose flag through to setup_logger()."""
|
||||
runner = CliRunner()
|
||||
|
||||
with (
|
||||
patch("astrbot_sdk.cli.setup_logger") as setup_logger_mock,
|
||||
patch("astrbot_sdk.cli.run_supervisor", new=Mock(return_value=object())),
|
||||
patch("astrbot_sdk.cli.asyncio.run"),
|
||||
):
|
||||
result = runner.invoke(cli, ["--verbose", "run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
setup_logger_mock.assert_called_once_with(True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "target", "kwargs"),
|
||||
[
|
||||
(
|
||||
["run", "--plugins-dir", "plugins-dev"],
|
||||
"run_supervisor",
|
||||
{"plugins_dir": Path("plugins-dev")},
|
||||
),
|
||||
(
|
||||
["worker", "--plugin-dir", "plugins/demo"],
|
||||
"run_plugin_worker",
|
||||
{"plugin_dir": Path("plugins/demo")},
|
||||
),
|
||||
(
|
||||
["websocket", "--port", "9000"],
|
||||
"run_websocket_server",
|
||||
{"port": 9000},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_cli_commands_delegate_to_bootstrap_functions(
|
||||
self, args, target: str, kwargs
|
||||
):
|
||||
"""Each CLI command should pass normalized arguments to its bootstrap entrypoint."""
|
||||
runner = CliRunner()
|
||||
sentinel = object()
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"astrbot_sdk.cli.{target}",
|
||||
new=Mock(return_value=sentinel),
|
||||
) as entrypoint_mock,
|
||||
patch("astrbot_sdk.cli.asyncio.run") as asyncio_run_mock,
|
||||
):
|
||||
result = runner.invoke(cli, args)
|
||||
|
||||
assert result.exit_code == 0
|
||||
entrypoint_mock.assert_called_once_with(**kwargs)
|
||||
asyncio_run_mock.assert_called_once_with(sentinel)
|
||||
|
||||
def test_main_module_invokes_cli_entrypoint(self):
|
||||
"""Running astrbot_sdk.__main__ as a script should call cli()."""
|
||||
cli_mock = Mock()
|
||||
|
||||
with patch("astrbot_sdk.cli.cli", cli_mock):
|
||||
runpy.run_module("astrbot_sdk.__main__", run_name="__main__")
|
||||
|
||||
cli_mock.assert_called_once_with()
|
||||
|
||||
|
||||
class TestErrorsModule:
|
||||
"""Tests for errors.py."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "args", "expected"),
|
||||
[
|
||||
(
|
||||
AstrBotError.cancelled,
|
||||
(),
|
||||
{
|
||||
"code": "cancelled",
|
||||
"message": "调用被取消",
|
||||
"hint": "",
|
||||
"retryable": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
AstrBotError.capability_not_found,
|
||||
("memory.save",),
|
||||
{
|
||||
"code": "capability_not_found",
|
||||
"message": "未找到能力:memory.save",
|
||||
"hint": "请确认 AstrBot Core 是否已注册该 capability",
|
||||
"retryable": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
AstrBotError.invalid_input,
|
||||
("bad payload",),
|
||||
{
|
||||
"code": "invalid_input",
|
||||
"message": "bad payload",
|
||||
"hint": "请检查调用参数",
|
||||
"retryable": False,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_error_factories_build_expected_payloads(self, factory, args, expected):
|
||||
"""Factory helpers should populate stable error payloads."""
|
||||
error = factory(*args)
|
||||
|
||||
assert str(error) == expected["message"]
|
||||
assert error.to_payload() == expected
|
||||
|
||||
def test_from_payload_applies_defaults_for_missing_fields(self):
|
||||
"""from_payload() should fill in the documented fallback values."""
|
||||
error = AstrBotError.from_payload({"message": "boom", "retryable": 1})
|
||||
|
||||
assert error.code == "unknown_error"
|
||||
assert error.message == "boom"
|
||||
assert error.hint == ""
|
||||
assert error.retryable is True
|
||||
|
||||
|
||||
class TestStarModule:
|
||||
"""Tests for star.py."""
|
||||
|
||||
def test_handlers_collect_across_inheritance(self):
|
||||
"""Star subclasses should inherit decorated handlers from base classes."""
|
||||
|
||||
class BasePlugin(Star):
|
||||
@on_command("base")
|
||||
async def base(self):
|
||||
return None
|
||||
|
||||
class ChildPlugin(BasePlugin):
|
||||
@on_command("child")
|
||||
async def child(self):
|
||||
return None
|
||||
|
||||
assert ChildPlugin.__handlers__ == ("base", "child")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected_reply"),
|
||||
[
|
||||
(
|
||||
AstrBotError(code="retryable", message="later", retryable=True),
|
||||
"请求失败,请稍后重试",
|
||||
),
|
||||
(AstrBotError.invalid_input("bad payload"), "请检查调用参数"),
|
||||
(AstrBotError(code="plain", message="plain failure"), "plain failure"),
|
||||
(RuntimeError("boom"), "出了点问题,请联系插件作者"),
|
||||
],
|
||||
)
|
||||
async def test_on_error_replies_with_expected_message(
|
||||
self, error, expected_reply: str
|
||||
):
|
||||
"""on_error() should translate failures into the expected user-facing reply."""
|
||||
event = AsyncMock()
|
||||
event.reply = AsyncMock()
|
||||
star = Star()
|
||||
|
||||
with patch("astrbot_sdk.star.logger.error") as log_error:
|
||||
await star.on_error(error, event, ctx=None)
|
||||
|
||||
event.reply.assert_awaited_once_with(expected_reply)
|
||||
log_error.assert_called_once()
|
||||
Reference in New Issue
Block a user