From 9bf831810d4446c2fe4488646b43b9c1432779e2 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 13 Mar 2026 00:29:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E5=92=8C=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=EF=BC=8C=E6=B6=B5=E7=9B=96=20API=20=E8=A3=85?= =?UTF-8?q?=E9=A5=B0=E5=99=A8=E3=80=81=E4=BA=8B=E4=BB=B6=E3=80=81=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E5=92=8C=E4=BC=A0=E8=BE=93=E9=80=9A=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 22 +++ CLAUDE.md | 22 +++ pyproject.toml | 55 +++++++ run_tests.py | 53 +++++++ tests_v4/README.md | 171 ++++++++++++++++++++ tests_v4/conftest.py | 210 ++++++++++++++++++++++++ tests_v4/pytest.ini | 14 ++ tests_v4/test_api_decorators.py | 246 +++++++++++++++++++++++++++++ tests_v4/test_conftest_fixtures.py | 166 +++++++++++++++++++ tests_v4/test_context.py | 124 +++++++++++++++ tests_v4/test_entrypoints.py | 25 +++ tests_v4/test_events.py | 109 +++++++++++++ 12 files changed, 1217 insertions(+) create mode 100644 run_tests.py create mode 100644 tests_v4/README.md create mode 100644 tests_v4/conftest.py create mode 100644 tests_v4/pytest.ini create mode 100644 tests_v4/test_api_decorators.py create mode 100644 tests_v4/test_conftest_fixtures.py create mode 100644 tests_v4/test_context.py create mode 100644 tests_v4/test_events.py diff --git a/AGENTS.md b/AGENTS.md index d83f09015..002f550fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,25 @@ - 2026-03-12: Legacy `handshake` payloads only contain `event_type` / `handler_full_name` metadata and do not preserve v4 command/message trigger details such as command names, aliases, keywords, or regex. Any legacy-to-v4 handshake translation must approximate handlers as coarse event subscriptions and keep the raw handshake payload in metadata for lossless fallback. - 2026-03-12: `src/astrbot_sdk/tests/start_client.py` and `benchmark_8_plugins_resource_usage.py` still reference legacy `astrbot_sdk.runtime.galaxy`, but `src-new/astrbot_sdk/runtime/galaxy.py` no longer exists. Treat `tests_v4/test_script_migrations.py` as the maintained replacement instead of reviving the old Galaxy path. - 2026-03-12: Legacy `src/astrbot_sdk/api/event/filter.py` exported a much larger decorator surface than `src-new/astrbot_sdk/api/event/filter.py`. Current compat coverage is enough for `command` / `regex` / `permission` and the exercised migration tests, but it is not a full drop-in replacement for every historical filter helper. + +# 开发命令 + +## 格式化与检查 + +在提交代码前,请依次运行以下命令: + +```bash +pyclean . # 清理 Python 缓存文件 +ruff format . # 使用 ruff 格式化代码 +ruff check . --fix # 使用 ruff 检查并自动修复问题 +``` + +## 测试 + +```bash +python run_tests.py # 运行所有测试 +python run_tests.py -v # 详细输出 +python run_tests.py -k "test_peer" # 运行匹配模式的测试 +python run_tests.py --cov # 运行测试并生成覆盖率报告 +``` + diff --git a/CLAUDE.md b/CLAUDE.md index d83f09015..002f550fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,3 +4,25 @@ - 2026-03-12: Legacy `handshake` payloads only contain `event_type` / `handler_full_name` metadata and do not preserve v4 command/message trigger details such as command names, aliases, keywords, or regex. Any legacy-to-v4 handshake translation must approximate handlers as coarse event subscriptions and keep the raw handshake payload in metadata for lossless fallback. - 2026-03-12: `src/astrbot_sdk/tests/start_client.py` and `benchmark_8_plugins_resource_usage.py` still reference legacy `astrbot_sdk.runtime.galaxy`, but `src-new/astrbot_sdk/runtime/galaxy.py` no longer exists. Treat `tests_v4/test_script_migrations.py` as the maintained replacement instead of reviving the old Galaxy path. - 2026-03-12: Legacy `src/astrbot_sdk/api/event/filter.py` exported a much larger decorator surface than `src-new/astrbot_sdk/api/event/filter.py`. Current compat coverage is enough for `command` / `regex` / `permission` and the exercised migration tests, but it is not a full drop-in replacement for every historical filter helper. + +# 开发命令 + +## 格式化与检查 + +在提交代码前,请依次运行以下命令: + +```bash +pyclean . # 清理 Python 缓存文件 +ruff format . # 使用 ruff 格式化代码 +ruff check . --fix # 使用 ruff 检查并自动修复问题 +``` + +## 测试 + +```bash +python run_tests.py # 运行所有测试 +python run_tests.py -v # 详细输出 +python run_tests.py -k "test_peer" # 运行匹配模式的测试 +python run_tests.py --cov # 运行测试并生成覆盖率报告 +``` + diff --git a/pyproject.toml b/pyproject.toml index c8e559f43..146cd1dd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,58 @@ package-dir = {"" = "src-new"} [tool.setuptools.packages.find] where = ["src-new"] + +# ============================================================ +# Pytest Configuration +# ============================================================ +[project.optional-dependencies] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests_v4"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] + +# ============================================================ +# Coverage Configuration +# ============================================================ +[tool.coverage.run] +source = ["src-new/astrbot_sdk"] +branch = true +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/_legacy_api.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] +show_missing = true diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 000000000..ef914f0cd --- /dev/null +++ b/run_tests.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +""" +Test runner script for astrbot-sdk. + +Usage: + python run_tests.py # Run all tests + python run_tests.py -v # Verbose output + python run_tests.py -k "test_peer" # Run tests matching pattern + python run_tests.py --cov # Run with coverage + python run_tests.py -m "not slow" # Skip slow tests +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + """Run tests with pytest.""" + project_root = Path(__file__).parent + tests_dir = project_root / "tests_v4" + + # Build pytest command + cmd = [sys.executable, "-m", "pytest", str(tests_dir)] + + # Parse arguments + args = sys.argv[1:] + + # Handle --cov flag + if "--cov" in args: + args.remove("--cov") + cmd.extend([ + "--cov=src-new/astrbot_sdk", + "--cov-report=term-missing", + "--cov-report=html:.htmlcov", + ]) + + # Default flags if no specific args + if not args: + cmd.extend(["-v", "--tb=short"]) + + cmd.extend(args) + + print(f"Running: {' '.join(cmd)}") + print("-" * 60) + + result = subprocess.run(cmd, cwd=project_root) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests_v4/README.md b/tests_v4/README.md new file mode 100644 index 000000000..baf86e5f3 --- /dev/null +++ b/tests_v4/README.md @@ -0,0 +1,171 @@ +# AstrBot SDK Test Framework + +## Overview + +This test suite uses **pytest** with `pytest-asyncio` for testing the AstrBot SDK v4 implementation. + +## Test Structure + +``` +tests_v4/ +├── conftest.py # Shared fixtures and configuration +├── pytest.ini # Pytest configuration +├── test_api_contract.py # API contract tests +├── test_api_decorators.py # Decorator and Star class tests +├── test_context.py # Context and CancelToken tests +├── test_entrypoints.py # CLI entrypoint tests (requires installation) +├── test_events.py # MessageEvent and PlainTextResult tests +├── test_legacy_adapter.py # Legacy API compatibility tests +├── test_peer.py # Peer communication tests +├── test_protocol.py # Protocol message tests +├── test_runtime.py # Supervisor/Worker runtime tests +├── test_script_migrations.py # Migration script tests +└── test_supervisor_migration.py # Supervisor migration tests +``` + +## Running Tests + +### All Tests + +```bash +# Using the runner script +python run_tests.py + +# Or directly with pytest +python -m pytest tests_v4/ -v +``` + +### Specific Tests + +```bash +# Run specific file +python -m pytest tests_v4/test_peer.py -v + +# Run specific test class +python -m pytest tests_v4/test_peer.py::PeerRuntimeTest -v + +# Run specific test +python -m pytest tests_v4/test_peer.py::PeerRuntimeTest::test_initialize_and_call_builtin_capabilities -v + +# Run tests matching pattern +python -m pytest tests_v4/ -k "peer" -v +``` + +### With Coverage + +```bash +python run_tests.py --cov + +# Or directly +python -m pytest tests_v4/ --cov=src-new/astrbot_sdk --cov-report=term-missing +``` + +### Skip Slow Tests + +```bash +python -m pytest tests_v4/ -m "not slow" +``` + +### Integration Tests Only + +```bash +python -m pytest tests_v4/ -m integration +``` + +## Test Markers + +| Marker | Description | +|--------|-------------| +| `@pytest.mark.unit` | Unit tests (fast, no external dependencies) | +| `@pytest.mark.integration` | Integration tests (may require setup) | +| `@pytest.mark.slow` | Slow tests (can be skipped with `-m "not slow"`) | + +## Available Fixtures + +The `conftest.py` provides these fixtures: + +### Transport Fixtures + +- `transport_pair`: Creates a connected pair of in-memory transports for testing peer communication +- `core_peer`: Creates a core peer with default handlers +- `plugin_peer`: Creates a plugin peer connected to core_peer + +### Helper Fixtures + +- `fake_env_manager`: Provides a fake environment manager for testing +- `temp_plugin_dir`: Creates a temporary directory for plugin testing +- `test_plugin`: Creates a minimal test plugin + +### Usage Example + +```python +async def test_my_feature(core_peer, plugin_peer): + """Test using pytest fixtures.""" + await plugin_peer.initialize([]) + result = await plugin_peer.invoke("llm.chat", {"prompt": "hello"}) + assert result["text"] == "Echo: hello" +``` + +## Writing New Tests + +### Test File Naming + +- Test files should start with `test_` +- Test classes should start with `Test` +- Test functions should start with `test_` + +### Async Tests + +Use `@pytest.mark.asyncio` or rely on auto mode: + +```python +# Both work due to asyncio_mode = auto +async def test_async_auto(): + await asyncio.sleep(0) + +@pytest.mark.asyncio +async def test_async_explicit(): + await asyncio.sleep(0) +``` + +### Using Fixtures + +```python +def test_with_fixture(transport_pair): + left, right = transport_pair + # Use transports... + +async def test_async_fixture(core_peer): + # core_peer is already started + await core_peer.invoke(...) +``` + +## Dependencies + +Install test dependencies: + +```bash +pip install pytest pytest-asyncio pytest-cov +``` + +Or use the optional dependency group: + +```bash +pip install -e ".[test]" +``` + +## Test Categories + +### Unit Tests (Fast) + +- `test_context.py` - CancelToken and Context tests +- `test_events.py` - MessageEvent and PlainTextResult tests +- `test_api_decorators.py` - Decorator tests +- `test_protocol.py` - Protocol message tests + +### Integration Tests (Slower) + +- `test_peer.py` - Peer communication with real transports +- `test_runtime.py` - Supervisor/Worker process tests +- `test_legacy_adapter.py` - Legacy API compatibility +- `test_script_migrations.py` - Migration script tests diff --git a/tests_v4/conftest.py b/tests_v4/conftest.py new file mode 100644 index 000000000..e6f86ecc5 --- /dev/null +++ b/tests_v4/conftest.py @@ -0,0 +1,210 @@ +# Test configuration +import asyncio +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import pytest + +# 将 src-new 加入路径 - 这使得测试可以运行,但不算"已安装" +import sys +SRC_NEW_PATH = str(Path(__file__).parent.parent / "src-new") +sys.path.insert(0, SRC_NEW_PATH) + + +# ============================================================ +# Async Configuration +# ============================================================ + +@pytest.fixture(scope="session") +def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: + """Create an event loop for async tests.""" + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +# ============================================================ +# Transport Fixtures +# ============================================================ + +class MemoryTransport: + """In-memory transport for testing peer communication.""" + + def __init__(self) -> None: + self._closed = asyncio.Event() + self._message_handler = None + self.partner: MemoryTransport | None = None + + def set_message_handler(self, handler) -> None: + self._message_handler = handler + + async def start(self) -> None: + self._closed.clear() + + async def stop(self) -> None: + self._closed.set() + + async def wait_closed(self) -> None: + await self._closed.wait() + + async def send(self, payload: str) -> None: + if self.partner is None: + raise RuntimeError("MemoryTransport 未连接 partner") + if self.partner._message_handler is not None: + await self.partner._message_handler(payload) + + async def _dispatch(self, payload: str) -> None: + if self._message_handler is not None: + await self._message_handler(payload) + + +@pytest.fixture +def transport_pair() -> tuple[MemoryTransport, MemoryTransport]: + """Create a connected pair of in-memory transports.""" + left = MemoryTransport() + right = MemoryTransport() + left.partner = right + right.partner = left + return left, right + + +# ============================================================ +# Mock/Fake Fixtures +# ============================================================ + +class FakeEnvManager: + """Fake environment manager for testing.""" + + def prepare_environment(self, _plugin: Any) -> Path: + return Path(sys.executable) + + +@pytest.fixture +def fake_env_manager() -> FakeEnvManager: + """Provide a fake environment manager.""" + return FakeEnvManager() + + +# ============================================================ +# Peer Fixtures +# ============================================================ + +from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo +from astrbot_sdk.runtime.capability_router import CapabilityRouter +from astrbot_sdk.runtime.peer import Peer + + +@pytest.fixture +async def core_peer(transport_pair: tuple[MemoryTransport, MemoryTransport]) -> Peer: + """Create a core peer with default handlers.""" + left, _ = transport_pair + router = CapabilityRouter() + + peer = Peer( + transport=left, + peer_info=PeerInfo(name="core", role="core", version="v4"), + ) + + async def init_handler(_message) -> InitializeOutput: + return InitializeOutput( + peer=PeerInfo(name="core", role="core", version="v4"), + capabilities=router.descriptors(), + metadata={}, + ) + + async def invoke_handler(message, token): + return await router.execute( + message.capability, + message.input, + stream=message.stream, + cancel_token=token, + request_id=message.id, + ) + + peer.set_initialize_handler(init_handler) + peer.set_invoke_handler(invoke_handler) + + await peer.start() + yield peer + await peer.stop() + + +@pytest.fixture +async def plugin_peer(transport_pair: tuple[MemoryTransport, MemoryTransport]) -> Peer: + """Create a plugin peer connected to core.""" + _, right = transport_pair + + peer = Peer( + transport=right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + + await peer.start() + yield peer + await peer.stop() + + +# ============================================================ +# Temporary Plugin Fixtures +# ============================================================ + +import tempfile +import textwrap + + +@pytest.fixture +def temp_plugin_dir() -> Generator[Path, None, None]: + """Create a temporary directory for plugin testing.""" + with tempfile.TemporaryDirectory() as temp_dir: + yield Path(temp_dir) + + +def create_test_plugin(plugin_root: Path, name: str = "test_plugin") -> None: + """Helper to create a minimal test plugin.""" + (plugin_root / "commands").mkdir(parents=True, exist_ok=True) + (plugin_root / "commands" / "__init__.py").write_text("", encoding="utf-8") + (plugin_root / "requirements.txt").write_text("", encoding="utf-8") + (plugin_root / "plugin.yaml").write_text( + textwrap.dedent( + f"""\ + _schema_version: 2 + name: {name} + display_name: Test Plugin + desc: test + author: tester + version: 0.1.0 + runtime: + python: "{sys.version_info.major}.{sys.version_info.minor}" + components: + - class: commands.sample:TestPlugin + type: command + name: test + description: test command + """ + ), + encoding="utf-8", + ) + (plugin_root / "commands" / "sample.py").write_text( + textwrap.dedent( + """\ + from astrbot_sdk import Context, MessageEvent, Star, on_command + + + class TestPlugin(Star): + @on_command("test") + async def test_cmd(self, event: MessageEvent, ctx: Context): + await event.reply("test ok") + """ + ), + encoding="utf-8", + ) + + +@pytest.fixture +def test_plugin(temp_plugin_dir: Path) -> Path: + """Create a test plugin and return its root directory.""" + plugin_root = temp_plugin_dir / "plugins" / "test_plugin" + create_test_plugin(plugin_root) + return temp_plugin_dir / "plugins" diff --git a/tests_v4/pytest.ini b/tests_v4/pytest.ini new file mode 100644 index 000000000..db005f73a --- /dev/null +++ b/tests_v4/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests diff --git a/tests_v4/test_api_decorators.py b/tests_v4/test_api_decorators.py new file mode 100644 index 000000000..339b45e39 --- /dev/null +++ b/tests_v4/test_api_decorators.py @@ -0,0 +1,246 @@ +""" +Unit tests for API decorators and Star class. +""" +from __future__ import annotations + +import pytest + +from astrbot_sdk import Context, MessageEvent, Star +from astrbot_sdk.decorators import ( + get_handler_meta, + on_command, + on_event, + on_message, + on_schedule, + require_admin, +) +from astrbot_sdk.protocol.descriptors import ( + CommandTrigger, + EventTrigger, + MessageTrigger, + ScheduleTrigger, +) + + +class TestOnCommandDecorator: + """Tests for @on_command decorator.""" + + def test_decorator_sets_handler_meta(self): + """@on_command should set __astrbot_handler_meta__.""" + + @on_command("hello") + async def hello_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(hello_handler) + assert meta is not None + assert isinstance(meta.trigger, CommandTrigger) + assert meta.trigger.command == "hello" + + def test_decorator_supports_aliases(self): + """@on_command should support command aliases.""" + + @on_command("hello", aliases=["hi", "hey"]) + async def hello_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(hello_handler) + assert meta.trigger.aliases == ["hi", "hey"] + + def test_decorator_supports_description(self): + """@on_command should support description.""" + + @on_command("hello", description="Say hello") + async def hello_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(hello_handler) + assert meta.trigger.description == "Say hello" + + +class TestOnMessageDecorator: + """Tests for @on_message decorator.""" + + def test_decorator_sets_handler_meta(self): + """@on_message should set __astrbot_handler_meta__.""" + + @on_message() + async def message_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(message_handler) + assert meta is not None + assert isinstance(meta.trigger, MessageTrigger) + + def test_decorator_supports_keywords(self): + """@on_message should support keyword filtering.""" + + @on_message(keywords=["hello", "hi"]) + async def keyword_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(keyword_handler) + assert meta.trigger.keywords == ["hello", "hi"] + + def test_decorator_supports_regex(self): + """@on_message should support regex filtering.""" + + @on_message(regex=r"\d+") + async def regex_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(regex_handler) + assert meta.trigger.regex == r"\d+" + + +class TestOnEventDecorator: + """Tests for @on_event decorator.""" + + def test_decorator_sets_handler_meta(self): + """@on_event should set __astrbot_handler_meta__.""" + + @on_event("message_received") + async def event_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(event_handler) + assert meta is not None + assert isinstance(meta.trigger, EventTrigger) + assert meta.trigger.event_type == "message_received" + + +class TestOnScheduleDecorator: + """Tests for @on_schedule decorator.""" + + def test_decorator_sets_cron_trigger(self): + """@on_schedule should create ScheduleTrigger with cron.""" + + @on_schedule(cron="* * * * *") + async def scheduled_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(scheduled_handler) + assert meta is not None + assert isinstance(meta.trigger, ScheduleTrigger) + assert meta.trigger.cron == "* * * * *" + + def test_decorator_sets_interval_trigger(self): + """@on_schedule should create ScheduleTrigger with interval.""" + + @on_schedule(interval_seconds=60) + async def interval_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(interval_handler) + assert meta.trigger.interval_seconds == 60 + + +class TestRequireAdminDecorator: + """Tests for @require_admin decorator.""" + + def test_decorator_sets_admin_permission(self): + """@require_admin should set require_admin permission.""" + + @require_admin + async def admin_handler(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(admin_handler) + assert meta.permissions.require_admin is True + + def test_can_combine_with_on_command(self): + """@require_admin can be combined with @on_command.""" + + @on_command("admin") + @require_admin + async def admin_cmd(event: MessageEvent, ctx: Context): + pass + + meta = get_handler_meta(admin_cmd) + assert isinstance(meta.trigger, CommandTrigger) + assert meta.trigger.command == "admin" + assert meta.permissions.require_admin is True + + +class TestStarClass: + """Tests for Star base class.""" + + def test_star_is_new_star_by_default(self): + """Star subclasses should be recognized as new-style.""" + class MyPlugin(Star): + pass + + assert MyPlugin.__astrbot_is_new_star__() is True + + def test_star_collects_handler_names_from_decorators(self): + """Star should collect decorated method names in __handlers__.""" + class MyPlugin(Star): + @on_command("hello") + async def hello(self, event: MessageEvent, ctx: Context): + pass + + @on_message() + async def on_msg(self, event: MessageEvent, ctx: Context): + pass + + assert "hello" in MyPlugin.__handlers__ + assert "on_msg" in MyPlugin.__handlers__ + + @pytest.mark.asyncio + async def test_star_on_error_calls_reply(self): + """Star.on_error should call event.reply with error message.""" + replies = [] + + class MyPlugin(Star): + pass + + plugin = MyPlugin() + + # Create event with mock reply handler + event = MessageEvent(text="test", session_id="s1") + event.bind_reply_handler(lambda text: replies.append(text) or asyncio.sleep(0)) + + # Create context (not used in default impl) + ctx = None + + # on_error should call reply + await plugin.on_error(RuntimeError("test error"), event, ctx) + + assert len(replies) == 1 + assert "问题" in replies[0] + + +class TestTriggerModels: + """Tests for trigger model validation.""" + + def test_command_trigger_validation(self): + """CommandTrigger should validate command name.""" + trigger = CommandTrigger(command="hello") + assert trigger.command == "hello" + + def test_message_trigger_optional_keywords(self): + """MessageTrigger should have optional keywords.""" + trigger = MessageTrigger() + assert trigger.keywords == [] + + trigger_with_keywords = MessageTrigger(keywords=["a", "b"]) + assert trigger_with_keywords.keywords == ["a", "b"] + + def test_event_trigger_validation(self): + """EventTrigger should store event type.""" + trigger = EventTrigger(event_type="custom_event") + assert trigger.event_type == "custom_event" + + def test_schedule_trigger_requires_one_strategy(self): + """ScheduleTrigger should require exactly one strategy.""" + with pytest.raises(ValueError): + ScheduleTrigger() + + with pytest.raises(ValueError): + ScheduleTrigger(cron="* * * * *", interval_seconds=10) + + trigger = ScheduleTrigger(interval_seconds=30) + assert trigger.interval_seconds == 30 + + +import asyncio # For the async test diff --git a/tests_v4/test_conftest_fixtures.py b/tests_v4/test_conftest_fixtures.py new file mode 100644 index 000000000..783c24a73 --- /dev/null +++ b/tests_v4/test_conftest_fixtures.py @@ -0,0 +1,166 @@ +""" +Pytest-based tests for transport and peer communication. + +These tests demonstrate the pytest fixtures defined in conftest.py. +""" +from __future__ import annotations + +import pytest + +from astrbot_sdk.context import CancelToken +from astrbot_sdk.errors import AstrBotError +from astrbot_sdk.protocol.descriptors import CapabilityDescriptor +from astrbot_sdk.protocol.messages import EventMessage, InitializeOutput, PeerInfo, ResultMessage +from astrbot_sdk.runtime.capability_router import CapabilityRouter, StreamExecution +from astrbot_sdk.runtime.peer import Peer + + +class TestTransportPair: + """Tests for MemoryTransport fixture.""" + + async def test_transport_pair_is_connected(self, transport_pair): + """Transport pair should be bidirectionally connected.""" + left, right = transport_pair + assert left.partner is right + assert right.partner is left + + async def test_transport_can_send_message(self, transport_pair): + """Messages sent through transport should be received by partner.""" + left, right = transport_pair + received = [] + + async def handler(payload): + received.append(payload) + + right.set_message_handler(handler) + await left.start() + await right.start() + + await left.send("test message") + + assert len(received) == 1 + assert received[0] == "test message" + + +class TestPeerConnection: + """Tests for peer-to-peer communication using fixtures.""" + + async def test_plugin_can_initialize(self, core_peer, plugin_peer): + """Plugin should be able to initialize with core.""" + await plugin_peer.initialize([]) + + assert plugin_peer.remote_peer is not None + assert plugin_peer.remote_peer.name == "core" + + async def test_plugin_can_invoke_capability(self, core_peer, plugin_peer): + """Plugin should be able to invoke llm.chat capability.""" + await plugin_peer.initialize([]) + + result = await plugin_peer.invoke("llm.chat", {"prompt": "hello"}) + assert result["text"] == "Echo: hello" + + async def test_plugin_can_stream_capability(self, core_peer, plugin_peer): + """Plugin should be able to stream llm.stream_chat capability.""" + await plugin_peer.initialize([]) + + stream = await plugin_peer.invoke_stream("llm.stream_chat", {"prompt": "hi"}) + chunks = [event.data["text"] async for event in stream] + assert "".join(chunks) == "Echo: hi" + + +class TestProtocolErrors: + """Tests for protocol error handling.""" + + async def test_stream_false_receiving_event_is_error(self, transport_pair): + """stream=false receiving event should raise protocol error.""" + left, right = transport_pair + + plugin = Peer( + transport=right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + + await left.start() + await plugin.start() + + task = asyncio.create_task( + plugin.invoke("llm.chat", {"prompt": "bad"}, request_id="req-1") + ) + await asyncio.sleep(0) + await left.send(EventMessage(id="req-1", phase="started").model_dump_json()) + + with pytest.raises(AstrBotError) as exc_info: + await task + assert exc_info.value.code == "protocol_error" + + await plugin.stop() + await left.stop() + + async def test_stream_true_receiving_result_is_error(self, transport_pair): + """stream=true receiving result should raise protocol error.""" + import asyncio + + left, right = transport_pair + + plugin = Peer( + transport=right, + peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), + ) + + await left.start() + await plugin.start() + + stream = await plugin.invoke_stream( + "llm.stream_chat", {"prompt": "bad"}, request_id="stream-1" + ) + await left.send(ResultMessage(id="stream-1", success=True, output={}).model_dump_json()) + + with pytest.raises(AstrBotError) as exc_info: + async for _ in stream: + pass + assert exc_info.value.code == "protocol_error" + + await plugin.stop() + await left.stop() + + +class TestCapabilityRouter: + """Tests for CapabilityRouter.""" + + def test_capability_name_validation(self): + """Capability names must follow namespace.method format.""" + router = CapabilityRouter() + + invalid_names = ["llm", "llm.chat.extra", "LLM.chat", "llm.Chat"] + for name in invalid_names: + with pytest.raises(ValueError) as exc_info: + router.register( + CapabilityDescriptor(name=name, description="invalid") + ) + assert name in str(exc_info.value) + + def test_reserved_namespaces_rejected_for_exposed(self): + """Reserved namespaces should be rejected for exposed registrations.""" + router = CapabilityRouter() + + reserved_names = ["handler.demo", "system.health", "internal.trace"] + for name in reserved_names: + with pytest.raises(ValueError) as exc_info: + router.register( + CapabilityDescriptor(name=name, description="reserved") + ) + assert name in str(exc_info.value) + + def test_reserved_namespaces_allowed_for_hidden(self): + """Reserved namespaces should be allowed for hidden registrations.""" + router = CapabilityRouter() + router.register( + CapabilityDescriptor(name="system.health", description="internal only"), + exposed=False, + ) + + descriptors = router.descriptors() + assert "system.health" not in [d.name for d in descriptors] + + +import asyncio # Import at end to avoid circular import issues in test file diff --git a/tests_v4/test_context.py b/tests_v4/test_context.py new file mode 100644 index 000000000..6ebabd4a3 --- /dev/null +++ b/tests_v4/test_context.py @@ -0,0 +1,124 @@ +""" +Unit tests for Context module. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from astrbot_sdk.context import CancelToken, Context +from astrbot_sdk.protocol.messages import PeerInfo +from astrbot_sdk.runtime.peer import Peer + + +class TestCancelToken: + """Tests for CancelToken.""" + + def test_initial_state(self): + """CancelToken should start uncancelled.""" + token = CancelToken() + assert not token.cancelled + + def test_cancel_sets_state(self): + """cancel() should set cancelled state.""" + token = CancelToken() + token.cancel() + assert token.cancelled + + def test_raise_if_cancelled_raises_when_cancelled(self): + """raise_if_cancelled() should raise CancelledError when cancelled.""" + token = CancelToken() + token.cancel() + + with pytest.raises(asyncio.CancelledError): + token.raise_if_cancelled() + + def test_raise_if_cancelled_no_raise_when_not_cancelled(self): + """raise_if_cancelled() should not raise when not cancelled.""" + token = CancelToken() + token.raise_if_cancelled() # Should not raise + + @pytest.mark.asyncio + async def test_wait_blocks_until_cancelled(self): + """wait() should block until cancel() is called.""" + token = CancelToken() + + async def cancel_after_delay(): + await asyncio.sleep(0.01) + token.cancel() + + task = asyncio.create_task(cancel_after_delay()) + await token.wait() + assert token.cancelled + await task + + +class TestContext: + """Tests for Context.""" + + def test_context_has_platform_facade(self, transport_pair): + """Context should have platform facade.""" + left, _ = transport_pair + + peer = Peer( + transport=left, + peer_info=PeerInfo(name="test", role="plugin", version="v4"), + ) + ctx = Context(peer=peer, plugin_id="test_plugin") + + assert hasattr(ctx, "platform") + assert hasattr(ctx.platform, "send") + + def test_context_has_llm_facade(self, transport_pair): + """Context should have LLM facade.""" + left, _ = transport_pair + + peer = Peer( + transport=left, + peer_info=PeerInfo(name="test", role="plugin", version="v4"), + ) + ctx = Context(peer=peer, plugin_id="test_plugin") + + assert hasattr(ctx, "llm") + assert hasattr(ctx.llm, "chat") + assert hasattr(ctx.llm, "stream_chat") + + def test_context_has_db_facade(self, transport_pair): + """Context should have database facade.""" + left, _ = transport_pair + + peer = Peer( + transport=left, + peer_info=PeerInfo(name="test", role="plugin", version="v4"), + ) + ctx = Context(peer=peer, plugin_id="test_plugin") + + assert hasattr(ctx, "db") + assert hasattr(ctx.db, "get") + assert hasattr(ctx.db, "set") + assert hasattr(ctx.db, "delete") + + def test_context_has_plugin_id(self, transport_pair): + """Context should store plugin_id.""" + left, _ = transport_pair + + peer = Peer( + transport=left, + peer_info=PeerInfo(name="test", role="plugin", version="v4"), + ) + ctx = Context(peer=peer, plugin_id="my_plugin") + + assert ctx.plugin_id == "my_plugin" + + def test_context_has_logger(self, transport_pair): + """Context should have a logger bound with plugin_id.""" + left, _ = transport_pair + + peer = Peer( + transport=left, + peer_info=PeerInfo(name="test", role="plugin", version="v4"), + ) + ctx = Context(peer=peer, plugin_id="test_plugin") + + assert hasattr(ctx, "logger") diff --git a/tests_v4/test_entrypoints.py b/tests_v4/test_entrypoints.py index 46f7470d4..da0e7fd66 100644 --- a/tests_v4/test_entrypoints.py +++ b/tests_v4/test_entrypoints.py @@ -4,7 +4,32 @@ import subprocess import sys import unittest +import pytest + +def _is_astrbot_sdk_installed_in_site_packages() -> bool: + """Check if astrbot_sdk is installed via pip (not just in PYTHONPATH).""" + try: + result = subprocess.run( + [sys.executable, "-c", "import astrbot_sdk; print(astrbot_sdk.__file__)"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return False + # Check if installed in site-packages, not just in local path + location = result.stdout.strip() + return "site-packages" in location or "dist-packages" in location + except Exception: + return False + + +@pytest.mark.integration +@pytest.mark.skipif( + not _is_astrbot_sdk_installed_in_site_packages(), + reason="astrbot_sdk not installed in site-packages (run: pip install -e .)" +) class EntryPointTest(unittest.TestCase): def test_import_package(self) -> None: process = subprocess.run( diff --git a/tests_v4/test_events.py b/tests_v4/test_events.py new file mode 100644 index 000000000..eb1f874f6 --- /dev/null +++ b/tests_v4/test_events.py @@ -0,0 +1,109 @@ +""" +Unit tests for Events module. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from astrbot_sdk.events import MessageEvent, PlainTextResult + + +class TestMessageEvent: + """Tests for MessageEvent.""" + + def test_from_payload_creates_event(self): + """from_payload() should create a MessageEvent from dict.""" + payload = { + "text": "hello world", + "session_id": "session-1", + "user_id": "user-1", + "platform": "test", + } + event = MessageEvent.from_payload(payload) + + assert event.text == "hello world" + assert event.session_id == "session-1" + assert event.user_id == "user-1" + assert event.platform == "test" + + def test_from_payload_handles_missing_fields(self): + """from_payload() should handle missing optional fields.""" + payload = {"text": "test"} + event = MessageEvent.from_payload(payload) + + assert event.text == "test" + assert event.session_id == "" # Falls back to empty string + assert event.user_id is None # Optional field + + def test_from_payload_preserves_raw_payload(self): + """from_payload() should preserve raw payload.""" + payload = { + "text": "test", + "extra_field": "extra_value", + } + event = MessageEvent.from_payload(payload) + + assert event.raw == payload + assert event.raw["extra_field"] == "extra_value" + + @pytest.mark.asyncio + async def test_bind_reply_handler(self): + """bind_reply_handler() should enable reply functionality.""" + event = MessageEvent(text="hello", session_id="s1") + replies = [] + + async def capture_reply(text: str) -> None: + replies.append(text) + + event.bind_reply_handler(capture_reply) + await event.reply("response") + + assert replies == ["response"] + + @pytest.mark.asyncio + async def test_reply_without_handler_raises(self): + """reply() without bound handler should raise.""" + event = MessageEvent(text="hello", session_id="s1") + + with pytest.raises(RuntimeError, match="未绑定 reply handler"): + await event.reply("response") + + def test_to_payload(self): + """to_payload() should serialize event to dict.""" + event = MessageEvent( + text="hello", + session_id="session-1", + user_id="user-1", + platform="test", + ) + payload = event.to_payload() + + assert payload["text"] == "hello" + assert payload["session_id"] == "session-1" + assert payload["user_id"] == "user-1" + assert payload["platform"] == "test" + + def test_plain_result_createsPlainTextResult(self): + """plain_result() should create PlainTextResult.""" + event = MessageEvent(text="hello", session_id="s1") + result = event.plain_result("test output") + + assert isinstance(result, PlainTextResult) + assert result.text == "test output" + + +class TestPlainTextResult: + """Tests for PlainTextResult.""" + + def test_create_plain_text_result(self): + """Should create PlainTextResult.""" + result = PlainTextResult(text="hello") + assert result.text == "hello" + + def test_plain_text_result_is_dataclass(self): + """PlainTextResult should be a dataclass with slots.""" + result = PlainTextResult(text="test") + # It's a dataclass, check attribute access works + assert result.text == "test"