From 644916772603ab6c80682f967599998bb6282731 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Tue, 17 Mar 2026 21:57:14 +0800 Subject: [PATCH] Remove obsolete test files for testing module, top-level modules, transport, and wire codecs - Deleted `test_testing_module.py` as it is no longer needed. - Removed `test_top_level_modules.py` which had no content. - Eliminated `test_transport.py` due to redundancy. - Cleared out `test_wire_codecs.py` as part of the cleanup. --- README.md | 29 + pyproject.toml | 59 +- run_tests.py | 56 - {astrbot_sdk => src/astrbot_sdk}/AGENTS.md | 0 {astrbot_sdk => src/astrbot_sdk}/__init__.py | 0 {astrbot_sdk => src/astrbot_sdk}/__main__.py | 0 .../astrbot_sdk}/_command_model.py | 0 .../astrbot_sdk}/_invocation_context.py | 0 .../astrbot_sdk}/_plugin_logger.py | 0 .../astrbot_sdk}/_star_runtime.py | 0 .../astrbot_sdk}/_testing_support.py | 0 .../astrbot_sdk}/_typing_utils.py | 0 {astrbot_sdk => src/astrbot_sdk}/cli.py | 0 .../astrbot_sdk}/clients/__init__.py | 0 .../astrbot_sdk}/clients/_proxy.py | 0 .../astrbot_sdk}/clients/db.py | 0 .../astrbot_sdk}/clients/files.py | 0 .../astrbot_sdk}/clients/http.py | 0 .../astrbot_sdk}/clients/llm.py | 0 .../astrbot_sdk}/clients/managers.py | 0 .../astrbot_sdk}/clients/memory.py | 0 .../astrbot_sdk}/clients/metadata.py | 0 .../astrbot_sdk}/clients/platform.py | 0 .../astrbot_sdk}/clients/provider.py | 0 .../astrbot_sdk}/clients/registry.py | 0 .../astrbot_sdk}/clients/session.py | 0 {astrbot_sdk => src/astrbot_sdk}/commands.py | 0 {astrbot_sdk => src/astrbot_sdk}/context.py | 0 .../astrbot_sdk}/conversation.py | 0 .../astrbot_sdk}/decorators.py | 0 .../astrbot_sdk}/docs/01_context_api.md | 0 .../docs/02_event_and_components.md | 0 .../astrbot_sdk}/docs/03_decorators.md | 0 .../astrbot_sdk}/docs/04_star_lifecycle.md | 0 .../astrbot_sdk}/docs/05_clients.md | 0 .../astrbot_sdk}/docs/06_error_handling.md | 0 .../astrbot_sdk}/docs/07_advanced_topics.md | 0 .../astrbot_sdk}/docs/08_testing_guide.md | 0 .../astrbot_sdk}/docs/09_api_reference.md | 0 .../astrbot_sdk}/docs/10_migration_guide.md | 0 .../docs/11_security_checklist.md | 0 .../astrbot_sdk}/docs/INDEX.md | 0 .../astrbot_sdk}/docs/PROJECT_ARCHITECTURE.md | 0 .../astrbot_sdk}/docs/README.md | 0 .../astrbot_sdk}/docs/api/clients.md | 0 .../astrbot_sdk}/docs/api/context.md | 0 .../astrbot_sdk}/docs/api/decorators.md | 0 .../astrbot_sdk}/docs/api/errors.md | 0 .../docs/api/message_components.md | 0 .../astrbot_sdk}/docs/api/message_event.md | 0 .../astrbot_sdk}/docs/api/message_result.md | 0 .../astrbot_sdk}/docs/api/star.md | 0 .../astrbot_sdk}/docs/api/types.md | 0 .../astrbot_sdk}/docs/api/utils.md | 0 {astrbot_sdk => src/astrbot_sdk}/errors.py | 0 {astrbot_sdk => src/astrbot_sdk}/events.py | 0 {astrbot_sdk => src/astrbot_sdk}/filters.py | 0 .../astrbot_sdk}/llm/__init__.py | 0 .../astrbot_sdk}/llm/agents.py | 0 .../astrbot_sdk}/llm/entities.py | 0 .../astrbot_sdk}/llm/providers.py | 0 {astrbot_sdk => src/astrbot_sdk}/llm/tools.py | 0 .../astrbot_sdk}/message_components.py | 0 .../astrbot_sdk}/message_result.py | 0 .../astrbot_sdk}/message_session.py | 0 {astrbot_sdk => src/astrbot_sdk}/plugin_kv.py | 0 .../astrbot_sdk}/protocol/__init__.py | 0 .../astrbot_sdk}/protocol/_builtin_schemas.py | 0 .../astrbot_sdk}/protocol/descriptors.py | 0 .../astrbot_sdk}/protocol/messages.py | 0 .../astrbot_sdk}/runtime/__init__.py | 0 .../runtime/_capability_router_builtins.py | 0 .../astrbot_sdk}/runtime/_loader_support.py | 0 .../astrbot_sdk}/runtime/_streaming.py | 0 .../astrbot_sdk}/runtime/bootstrap.py | 3 + .../runtime/capability_dispatcher.py | 0 .../astrbot_sdk}/runtime/capability_router.py | 0 .../runtime/environment_groups.py | 21 +- .../runtime/handler_dispatcher.py | 0 .../astrbot_sdk}/runtime/limiter.py | 0 .../astrbot_sdk}/runtime/loader.py | 0 .../astrbot_sdk}/runtime/peer.py | 0 .../astrbot_sdk}/runtime/supervisor.py | 2 +- .../astrbot_sdk}/runtime/transport.py | 0 .../astrbot_sdk}/runtime/worker.py | 0 {astrbot_sdk => src/astrbot_sdk}/schedule.py | 0 .../astrbot_sdk}/session_waiter.py | 0 {astrbot_sdk => src/astrbot_sdk}/star.py | 0 .../astrbot_sdk}/star_tools.py | 0 {astrbot_sdk => src/astrbot_sdk}/testing.py | 0 {astrbot_sdk => src/astrbot_sdk}/types.py | 0 tests_v4/README.md | 172 --- tests_v4/__init__.py | 1 - tests_v4/conftest.py | 327 ----- tests_v4/helpers.py | 125 -- tests_v4/test_api_decorators.py | 248 ---- tests_v4/test_capability_proxy.py | 323 ----- tests_v4/test_capability_router.py | 1236 ----------------- tests_v4/test_clients_module.py | 89 -- tests_v4/test_conftest_fixtures.py | 166 --- tests_v4/test_context.py | 137 -- tests_v4/test_db_client.py | 339 ----- tests_v4/test_decorators.py | 425 ------ tests_v4/test_entrypoints.py | 95 -- tests_v4/test_events.py | 190 --- tests_v4/test_handler_dispatcher.py | 139 -- tests_v4/test_http_metadata_clients.py | 389 ------ tests_v4/test_llm_client.py | 312 ----- tests_v4/test_memory_client.py | 381 ----- tests_v4/test_peer.py | 666 --------- tests_v4/test_platform_client.py | 240 ---- tests_v4/test_protocol.py | 53 - tests_v4/test_protocol_descriptors.py | 418 ------ tests_v4/test_protocol_messages.py | 585 -------- tests_v4/test_testing_module.py | 315 ----- tests_v4/test_top_level_modules.py | 0 tests_v4/test_transport.py | 587 -------- tests_v4/test_wire_codecs.py | 55 - 118 files changed, 54 insertions(+), 8129 deletions(-) create mode 100644 README.md delete mode 100644 run_tests.py rename {astrbot_sdk => src/astrbot_sdk}/AGENTS.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/__init__.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/__main__.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/_command_model.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/_invocation_context.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/_plugin_logger.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/_star_runtime.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/_testing_support.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/_typing_utils.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/cli.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/__init__.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/_proxy.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/db.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/files.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/http.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/llm.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/managers.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/memory.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/metadata.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/platform.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/provider.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/registry.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/clients/session.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/commands.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/context.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/conversation.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/decorators.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/01_context_api.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/02_event_and_components.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/03_decorators.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/04_star_lifecycle.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/05_clients.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/06_error_handling.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/07_advanced_topics.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/08_testing_guide.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/09_api_reference.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/10_migration_guide.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/11_security_checklist.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/INDEX.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/PROJECT_ARCHITECTURE.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/README.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/clients.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/context.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/decorators.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/errors.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/message_components.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/message_event.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/message_result.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/star.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/types.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/docs/api/utils.md (100%) rename {astrbot_sdk => src/astrbot_sdk}/errors.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/events.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/filters.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/llm/__init__.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/llm/agents.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/llm/entities.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/llm/providers.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/llm/tools.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/message_components.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/message_result.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/message_session.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/plugin_kv.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/protocol/__init__.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/protocol/_builtin_schemas.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/protocol/descriptors.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/protocol/messages.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/__init__.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/_capability_router_builtins.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/_loader_support.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/_streaming.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/bootstrap.py (94%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/capability_dispatcher.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/capability_router.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/environment_groups.py (98%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/handler_dispatcher.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/limiter.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/loader.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/peer.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/supervisor.py (99%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/transport.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/runtime/worker.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/schedule.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/session_waiter.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/star.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/star_tools.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/testing.py (100%) rename {astrbot_sdk => src/astrbot_sdk}/types.py (100%) delete mode 100644 tests_v4/README.md delete mode 100644 tests_v4/__init__.py delete mode 100644 tests_v4/conftest.py delete mode 100644 tests_v4/helpers.py delete mode 100644 tests_v4/test_api_decorators.py delete mode 100644 tests_v4/test_capability_proxy.py delete mode 100644 tests_v4/test_capability_router.py delete mode 100644 tests_v4/test_clients_module.py delete mode 100644 tests_v4/test_conftest_fixtures.py delete mode 100644 tests_v4/test_context.py delete mode 100644 tests_v4/test_db_client.py delete mode 100644 tests_v4/test_decorators.py delete mode 100644 tests_v4/test_entrypoints.py delete mode 100644 tests_v4/test_events.py delete mode 100644 tests_v4/test_handler_dispatcher.py delete mode 100644 tests_v4/test_http_metadata_clients.py delete mode 100644 tests_v4/test_llm_client.py delete mode 100644 tests_v4/test_memory_client.py delete mode 100644 tests_v4/test_peer.py delete mode 100644 tests_v4/test_platform_client.py delete mode 100644 tests_v4/test_protocol.py delete mode 100644 tests_v4/test_protocol_descriptors.py delete mode 100644 tests_v4/test_protocol_messages.py delete mode 100644 tests_v4/test_testing_module.py delete mode 100644 tests_v4/test_top_level_modules.py delete mode 100644 tests_v4/test_transport.py delete mode 100644 tests_v4/test_wire_codecs.py diff --git a/README.md b/README.md new file mode 100644 index 000000000..1b6a32fc5 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# AstrBot SDK + +AstrBot 插件开发 SDK,提供 v4 runtime、worker protocol 和插件工具链。 + +## 安装 + +```bash +pip install astrbot-sdk +``` + +## 开发安装 + +```bash +# 克隆仓库后 +pip install -e . + +# 或使用 uv +uv sync +``` + +## 目录结构 + +``` +astrbot-sdk/ +├── src/ +│ └── astrbot_sdk/ # SDK 主包 +├── pyproject.toml +└── README.md +``` diff --git a/pyproject.toml b/pyproject.toml index 20cdf9083..0c6fbddae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,64 +26,19 @@ dependencies = [ astr = "astrbot_sdk.cli:cli" astrbot-sdk = "astrbot_sdk.cli:cli" -[tool.setuptools] -package-dir = {"" = "src-new"} - +# ============================================================ +# Package Discovery (src layout) +# ============================================================ [tool.setuptools.packages.find] -where = ["src-new"] +where = ["src"] # ============================================================ -# Pytest Configuration +# Optional Dependencies # ============================================================ [project.optional-dependencies] -test = [ +dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0", + "ruff>=0.4.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", - "*/plugins/*" -] - -[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 deleted file mode 100644 index 3f779af57..000000000 --- a/run_tests.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/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/astrbot_sdk/AGENTS.md b/src/astrbot_sdk/AGENTS.md similarity index 100% rename from astrbot_sdk/AGENTS.md rename to src/astrbot_sdk/AGENTS.md diff --git a/astrbot_sdk/__init__.py b/src/astrbot_sdk/__init__.py similarity index 100% rename from astrbot_sdk/__init__.py rename to src/astrbot_sdk/__init__.py diff --git a/astrbot_sdk/__main__.py b/src/astrbot_sdk/__main__.py similarity index 100% rename from astrbot_sdk/__main__.py rename to src/astrbot_sdk/__main__.py diff --git a/astrbot_sdk/_command_model.py b/src/astrbot_sdk/_command_model.py similarity index 100% rename from astrbot_sdk/_command_model.py rename to src/astrbot_sdk/_command_model.py diff --git a/astrbot_sdk/_invocation_context.py b/src/astrbot_sdk/_invocation_context.py similarity index 100% rename from astrbot_sdk/_invocation_context.py rename to src/astrbot_sdk/_invocation_context.py diff --git a/astrbot_sdk/_plugin_logger.py b/src/astrbot_sdk/_plugin_logger.py similarity index 100% rename from astrbot_sdk/_plugin_logger.py rename to src/astrbot_sdk/_plugin_logger.py diff --git a/astrbot_sdk/_star_runtime.py b/src/astrbot_sdk/_star_runtime.py similarity index 100% rename from astrbot_sdk/_star_runtime.py rename to src/astrbot_sdk/_star_runtime.py diff --git a/astrbot_sdk/_testing_support.py b/src/astrbot_sdk/_testing_support.py similarity index 100% rename from astrbot_sdk/_testing_support.py rename to src/astrbot_sdk/_testing_support.py diff --git a/astrbot_sdk/_typing_utils.py b/src/astrbot_sdk/_typing_utils.py similarity index 100% rename from astrbot_sdk/_typing_utils.py rename to src/astrbot_sdk/_typing_utils.py diff --git a/astrbot_sdk/cli.py b/src/astrbot_sdk/cli.py similarity index 100% rename from astrbot_sdk/cli.py rename to src/astrbot_sdk/cli.py diff --git a/astrbot_sdk/clients/__init__.py b/src/astrbot_sdk/clients/__init__.py similarity index 100% rename from astrbot_sdk/clients/__init__.py rename to src/astrbot_sdk/clients/__init__.py diff --git a/astrbot_sdk/clients/_proxy.py b/src/astrbot_sdk/clients/_proxy.py similarity index 100% rename from astrbot_sdk/clients/_proxy.py rename to src/astrbot_sdk/clients/_proxy.py diff --git a/astrbot_sdk/clients/db.py b/src/astrbot_sdk/clients/db.py similarity index 100% rename from astrbot_sdk/clients/db.py rename to src/astrbot_sdk/clients/db.py diff --git a/astrbot_sdk/clients/files.py b/src/astrbot_sdk/clients/files.py similarity index 100% rename from astrbot_sdk/clients/files.py rename to src/astrbot_sdk/clients/files.py diff --git a/astrbot_sdk/clients/http.py b/src/astrbot_sdk/clients/http.py similarity index 100% rename from astrbot_sdk/clients/http.py rename to src/astrbot_sdk/clients/http.py diff --git a/astrbot_sdk/clients/llm.py b/src/astrbot_sdk/clients/llm.py similarity index 100% rename from astrbot_sdk/clients/llm.py rename to src/astrbot_sdk/clients/llm.py diff --git a/astrbot_sdk/clients/managers.py b/src/astrbot_sdk/clients/managers.py similarity index 100% rename from astrbot_sdk/clients/managers.py rename to src/astrbot_sdk/clients/managers.py diff --git a/astrbot_sdk/clients/memory.py b/src/astrbot_sdk/clients/memory.py similarity index 100% rename from astrbot_sdk/clients/memory.py rename to src/astrbot_sdk/clients/memory.py diff --git a/astrbot_sdk/clients/metadata.py b/src/astrbot_sdk/clients/metadata.py similarity index 100% rename from astrbot_sdk/clients/metadata.py rename to src/astrbot_sdk/clients/metadata.py diff --git a/astrbot_sdk/clients/platform.py b/src/astrbot_sdk/clients/platform.py similarity index 100% rename from astrbot_sdk/clients/platform.py rename to src/astrbot_sdk/clients/platform.py diff --git a/astrbot_sdk/clients/provider.py b/src/astrbot_sdk/clients/provider.py similarity index 100% rename from astrbot_sdk/clients/provider.py rename to src/astrbot_sdk/clients/provider.py diff --git a/astrbot_sdk/clients/registry.py b/src/astrbot_sdk/clients/registry.py similarity index 100% rename from astrbot_sdk/clients/registry.py rename to src/astrbot_sdk/clients/registry.py diff --git a/astrbot_sdk/clients/session.py b/src/astrbot_sdk/clients/session.py similarity index 100% rename from astrbot_sdk/clients/session.py rename to src/astrbot_sdk/clients/session.py diff --git a/astrbot_sdk/commands.py b/src/astrbot_sdk/commands.py similarity index 100% rename from astrbot_sdk/commands.py rename to src/astrbot_sdk/commands.py diff --git a/astrbot_sdk/context.py b/src/astrbot_sdk/context.py similarity index 100% rename from astrbot_sdk/context.py rename to src/astrbot_sdk/context.py diff --git a/astrbot_sdk/conversation.py b/src/astrbot_sdk/conversation.py similarity index 100% rename from astrbot_sdk/conversation.py rename to src/astrbot_sdk/conversation.py diff --git a/astrbot_sdk/decorators.py b/src/astrbot_sdk/decorators.py similarity index 100% rename from astrbot_sdk/decorators.py rename to src/astrbot_sdk/decorators.py diff --git a/astrbot_sdk/docs/01_context_api.md b/src/astrbot_sdk/docs/01_context_api.md similarity index 100% rename from astrbot_sdk/docs/01_context_api.md rename to src/astrbot_sdk/docs/01_context_api.md diff --git a/astrbot_sdk/docs/02_event_and_components.md b/src/astrbot_sdk/docs/02_event_and_components.md similarity index 100% rename from astrbot_sdk/docs/02_event_and_components.md rename to src/astrbot_sdk/docs/02_event_and_components.md diff --git a/astrbot_sdk/docs/03_decorators.md b/src/astrbot_sdk/docs/03_decorators.md similarity index 100% rename from astrbot_sdk/docs/03_decorators.md rename to src/astrbot_sdk/docs/03_decorators.md diff --git a/astrbot_sdk/docs/04_star_lifecycle.md b/src/astrbot_sdk/docs/04_star_lifecycle.md similarity index 100% rename from astrbot_sdk/docs/04_star_lifecycle.md rename to src/astrbot_sdk/docs/04_star_lifecycle.md diff --git a/astrbot_sdk/docs/05_clients.md b/src/astrbot_sdk/docs/05_clients.md similarity index 100% rename from astrbot_sdk/docs/05_clients.md rename to src/astrbot_sdk/docs/05_clients.md diff --git a/astrbot_sdk/docs/06_error_handling.md b/src/astrbot_sdk/docs/06_error_handling.md similarity index 100% rename from astrbot_sdk/docs/06_error_handling.md rename to src/astrbot_sdk/docs/06_error_handling.md diff --git a/astrbot_sdk/docs/07_advanced_topics.md b/src/astrbot_sdk/docs/07_advanced_topics.md similarity index 100% rename from astrbot_sdk/docs/07_advanced_topics.md rename to src/astrbot_sdk/docs/07_advanced_topics.md diff --git a/astrbot_sdk/docs/08_testing_guide.md b/src/astrbot_sdk/docs/08_testing_guide.md similarity index 100% rename from astrbot_sdk/docs/08_testing_guide.md rename to src/astrbot_sdk/docs/08_testing_guide.md diff --git a/astrbot_sdk/docs/09_api_reference.md b/src/astrbot_sdk/docs/09_api_reference.md similarity index 100% rename from astrbot_sdk/docs/09_api_reference.md rename to src/astrbot_sdk/docs/09_api_reference.md diff --git a/astrbot_sdk/docs/10_migration_guide.md b/src/astrbot_sdk/docs/10_migration_guide.md similarity index 100% rename from astrbot_sdk/docs/10_migration_guide.md rename to src/astrbot_sdk/docs/10_migration_guide.md diff --git a/astrbot_sdk/docs/11_security_checklist.md b/src/astrbot_sdk/docs/11_security_checklist.md similarity index 100% rename from astrbot_sdk/docs/11_security_checklist.md rename to src/astrbot_sdk/docs/11_security_checklist.md diff --git a/astrbot_sdk/docs/INDEX.md b/src/astrbot_sdk/docs/INDEX.md similarity index 100% rename from astrbot_sdk/docs/INDEX.md rename to src/astrbot_sdk/docs/INDEX.md diff --git a/astrbot_sdk/docs/PROJECT_ARCHITECTURE.md b/src/astrbot_sdk/docs/PROJECT_ARCHITECTURE.md similarity index 100% rename from astrbot_sdk/docs/PROJECT_ARCHITECTURE.md rename to src/astrbot_sdk/docs/PROJECT_ARCHITECTURE.md diff --git a/astrbot_sdk/docs/README.md b/src/astrbot_sdk/docs/README.md similarity index 100% rename from astrbot_sdk/docs/README.md rename to src/astrbot_sdk/docs/README.md diff --git a/astrbot_sdk/docs/api/clients.md b/src/astrbot_sdk/docs/api/clients.md similarity index 100% rename from astrbot_sdk/docs/api/clients.md rename to src/astrbot_sdk/docs/api/clients.md diff --git a/astrbot_sdk/docs/api/context.md b/src/astrbot_sdk/docs/api/context.md similarity index 100% rename from astrbot_sdk/docs/api/context.md rename to src/astrbot_sdk/docs/api/context.md diff --git a/astrbot_sdk/docs/api/decorators.md b/src/astrbot_sdk/docs/api/decorators.md similarity index 100% rename from astrbot_sdk/docs/api/decorators.md rename to src/astrbot_sdk/docs/api/decorators.md diff --git a/astrbot_sdk/docs/api/errors.md b/src/astrbot_sdk/docs/api/errors.md similarity index 100% rename from astrbot_sdk/docs/api/errors.md rename to src/astrbot_sdk/docs/api/errors.md diff --git a/astrbot_sdk/docs/api/message_components.md b/src/astrbot_sdk/docs/api/message_components.md similarity index 100% rename from astrbot_sdk/docs/api/message_components.md rename to src/astrbot_sdk/docs/api/message_components.md diff --git a/astrbot_sdk/docs/api/message_event.md b/src/astrbot_sdk/docs/api/message_event.md similarity index 100% rename from astrbot_sdk/docs/api/message_event.md rename to src/astrbot_sdk/docs/api/message_event.md diff --git a/astrbot_sdk/docs/api/message_result.md b/src/astrbot_sdk/docs/api/message_result.md similarity index 100% rename from astrbot_sdk/docs/api/message_result.md rename to src/astrbot_sdk/docs/api/message_result.md diff --git a/astrbot_sdk/docs/api/star.md b/src/astrbot_sdk/docs/api/star.md similarity index 100% rename from astrbot_sdk/docs/api/star.md rename to src/astrbot_sdk/docs/api/star.md diff --git a/astrbot_sdk/docs/api/types.md b/src/astrbot_sdk/docs/api/types.md similarity index 100% rename from astrbot_sdk/docs/api/types.md rename to src/astrbot_sdk/docs/api/types.md diff --git a/astrbot_sdk/docs/api/utils.md b/src/astrbot_sdk/docs/api/utils.md similarity index 100% rename from astrbot_sdk/docs/api/utils.md rename to src/astrbot_sdk/docs/api/utils.md diff --git a/astrbot_sdk/errors.py b/src/astrbot_sdk/errors.py similarity index 100% rename from astrbot_sdk/errors.py rename to src/astrbot_sdk/errors.py diff --git a/astrbot_sdk/events.py b/src/astrbot_sdk/events.py similarity index 100% rename from astrbot_sdk/events.py rename to src/astrbot_sdk/events.py diff --git a/astrbot_sdk/filters.py b/src/astrbot_sdk/filters.py similarity index 100% rename from astrbot_sdk/filters.py rename to src/astrbot_sdk/filters.py diff --git a/astrbot_sdk/llm/__init__.py b/src/astrbot_sdk/llm/__init__.py similarity index 100% rename from astrbot_sdk/llm/__init__.py rename to src/astrbot_sdk/llm/__init__.py diff --git a/astrbot_sdk/llm/agents.py b/src/astrbot_sdk/llm/agents.py similarity index 100% rename from astrbot_sdk/llm/agents.py rename to src/astrbot_sdk/llm/agents.py diff --git a/astrbot_sdk/llm/entities.py b/src/astrbot_sdk/llm/entities.py similarity index 100% rename from astrbot_sdk/llm/entities.py rename to src/astrbot_sdk/llm/entities.py diff --git a/astrbot_sdk/llm/providers.py b/src/astrbot_sdk/llm/providers.py similarity index 100% rename from astrbot_sdk/llm/providers.py rename to src/astrbot_sdk/llm/providers.py diff --git a/astrbot_sdk/llm/tools.py b/src/astrbot_sdk/llm/tools.py similarity index 100% rename from astrbot_sdk/llm/tools.py rename to src/astrbot_sdk/llm/tools.py diff --git a/astrbot_sdk/message_components.py b/src/astrbot_sdk/message_components.py similarity index 100% rename from astrbot_sdk/message_components.py rename to src/astrbot_sdk/message_components.py diff --git a/astrbot_sdk/message_result.py b/src/astrbot_sdk/message_result.py similarity index 100% rename from astrbot_sdk/message_result.py rename to src/astrbot_sdk/message_result.py diff --git a/astrbot_sdk/message_session.py b/src/astrbot_sdk/message_session.py similarity index 100% rename from astrbot_sdk/message_session.py rename to src/astrbot_sdk/message_session.py diff --git a/astrbot_sdk/plugin_kv.py b/src/astrbot_sdk/plugin_kv.py similarity index 100% rename from astrbot_sdk/plugin_kv.py rename to src/astrbot_sdk/plugin_kv.py diff --git a/astrbot_sdk/protocol/__init__.py b/src/astrbot_sdk/protocol/__init__.py similarity index 100% rename from astrbot_sdk/protocol/__init__.py rename to src/astrbot_sdk/protocol/__init__.py diff --git a/astrbot_sdk/protocol/_builtin_schemas.py b/src/astrbot_sdk/protocol/_builtin_schemas.py similarity index 100% rename from astrbot_sdk/protocol/_builtin_schemas.py rename to src/astrbot_sdk/protocol/_builtin_schemas.py diff --git a/astrbot_sdk/protocol/descriptors.py b/src/astrbot_sdk/protocol/descriptors.py similarity index 100% rename from astrbot_sdk/protocol/descriptors.py rename to src/astrbot_sdk/protocol/descriptors.py diff --git a/astrbot_sdk/protocol/messages.py b/src/astrbot_sdk/protocol/messages.py similarity index 100% rename from astrbot_sdk/protocol/messages.py rename to src/astrbot_sdk/protocol/messages.py diff --git a/astrbot_sdk/runtime/__init__.py b/src/astrbot_sdk/runtime/__init__.py similarity index 100% rename from astrbot_sdk/runtime/__init__.py rename to src/astrbot_sdk/runtime/__init__.py diff --git a/astrbot_sdk/runtime/_capability_router_builtins.py b/src/astrbot_sdk/runtime/_capability_router_builtins.py similarity index 100% rename from astrbot_sdk/runtime/_capability_router_builtins.py rename to src/astrbot_sdk/runtime/_capability_router_builtins.py diff --git a/astrbot_sdk/runtime/_loader_support.py b/src/astrbot_sdk/runtime/_loader_support.py similarity index 100% rename from astrbot_sdk/runtime/_loader_support.py rename to src/astrbot_sdk/runtime/_loader_support.py diff --git a/astrbot_sdk/runtime/_streaming.py b/src/astrbot_sdk/runtime/_streaming.py similarity index 100% rename from astrbot_sdk/runtime/_streaming.py rename to src/astrbot_sdk/runtime/_streaming.py diff --git a/astrbot_sdk/runtime/bootstrap.py b/src/astrbot_sdk/runtime/bootstrap.py similarity index 94% rename from astrbot_sdk/runtime/bootstrap.py rename to src/astrbot_sdk/runtime/bootstrap.py index 7a8706965..a08208f91 100644 --- a/astrbot_sdk/runtime/bootstrap.py +++ b/src/astrbot_sdk/runtime/bootstrap.py @@ -98,6 +98,9 @@ async def run_plugin_worker( transport=transport, ) else: + # 前置互斥校验已保证单插件模式下 plugin_dir 一定存在;这里显式收窄, + # 避免把入口层的 Optional 继续传播到单插件运行时。 + assert plugin_dir is not None runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) try: await runtime.start() diff --git a/astrbot_sdk/runtime/capability_dispatcher.py b/src/astrbot_sdk/runtime/capability_dispatcher.py similarity index 100% rename from astrbot_sdk/runtime/capability_dispatcher.py rename to src/astrbot_sdk/runtime/capability_dispatcher.py diff --git a/astrbot_sdk/runtime/capability_router.py b/src/astrbot_sdk/runtime/capability_router.py similarity index 100% rename from astrbot_sdk/runtime/capability_router.py rename to src/astrbot_sdk/runtime/capability_router.py diff --git a/astrbot_sdk/runtime/environment_groups.py b/src/astrbot_sdk/runtime/environment_groups.py similarity index 98% rename from astrbot_sdk/runtime/environment_groups.py rename to src/astrbot_sdk/runtime/environment_groups.py index b742d66ec..982aaa297 100644 --- a/astrbot_sdk/runtime/environment_groups.py +++ b/src/astrbot_sdk/runtime/environment_groups.py @@ -43,6 +43,12 @@ _PYVENV_VERSION_PATTERN = re.compile( ) +def _require_uv_binary(uv_binary: str | None) -> str: + if not uv_binary: + raise RuntimeError("uv executable not found") + return uv_binary + + def _venv_python_path(venv_path: Path) -> Path: if os.name == "nt": return venv_path / "Scripts" / "python.exe" @@ -148,8 +154,7 @@ class EnvironmentPlanner: if not plugins: self.cleanup_artifacts([]) return EnvironmentPlanResult() - if not self.uv_binary: - raise RuntimeError("uv executable not found") + _require_uv_binary(self.uv_binary) candidate_groups = self._build_candidate_groups(plugins) planned_groups: list[EnvironmentGroup] = [] @@ -397,9 +402,10 @@ class EnvironmentPlanner: python_version: str, ) -> None: """把依赖求解委托给 `uv pip compile`。""" + uv_binary = _require_uv_binary(self.uv_binary) self._run_command( [ - self.uv_binary, + uv_binary, "pip", "compile", "--python-version", @@ -549,8 +555,7 @@ class GroupEnvironmentManager: - 环境结构还在但指纹变化:执行 `uv pip sync` - 否则:直接复用现有解释器路径 """ - if not self.uv_binary: - raise RuntimeError("uv executable not found") + _require_uv_binary(self.uv_binary) state_path = group.venv_path / GROUP_STATE_FILE_NAME state = self._load_state(state_path) @@ -577,9 +582,10 @@ class GroupEnvironmentManager: def _sync_lockfile(self, group: EnvironmentGroup) -> None: """让已安装包与该分组的 lockfile 精确对齐。""" + uv_binary = _require_uv_binary(self.uv_binary) self._run_command( [ - self.uv_binary, + uv_binary, "pip", "sync", "--python", @@ -597,9 +603,10 @@ class GroupEnvironmentManager: 当前迁移阶段仍保留 `--system-site-packages`,以兼容那些仍然隐式依 赖宿主环境包的旧插件。 """ + uv_binary = _require_uv_binary(self.uv_binary) self._run_command( [ - self.uv_binary, + uv_binary, "venv", "--python", group.python_version, diff --git a/astrbot_sdk/runtime/handler_dispatcher.py b/src/astrbot_sdk/runtime/handler_dispatcher.py similarity index 100% rename from astrbot_sdk/runtime/handler_dispatcher.py rename to src/astrbot_sdk/runtime/handler_dispatcher.py diff --git a/astrbot_sdk/runtime/limiter.py b/src/astrbot_sdk/runtime/limiter.py similarity index 100% rename from astrbot_sdk/runtime/limiter.py rename to src/astrbot_sdk/runtime/limiter.py diff --git a/astrbot_sdk/runtime/loader.py b/src/astrbot_sdk/runtime/loader.py similarity index 100% rename from astrbot_sdk/runtime/loader.py rename to src/astrbot_sdk/runtime/loader.py diff --git a/astrbot_sdk/runtime/peer.py b/src/astrbot_sdk/runtime/peer.py similarity index 100% rename from astrbot_sdk/runtime/peer.py rename to src/astrbot_sdk/runtime/peer.py diff --git a/astrbot_sdk/runtime/supervisor.py b/src/astrbot_sdk/runtime/supervisor.py similarity index 99% rename from astrbot_sdk/runtime/supervisor.py rename to src/astrbot_sdk/runtime/supervisor.py index 1b86a303e..c0e899924 100644 --- a/astrbot_sdk/runtime/supervisor.py +++ b/src/astrbot_sdk/runtime/supervisor.py @@ -93,7 +93,7 @@ def _prepare_stdio_transport( def _sdk_source_dir(repo_root: Path) -> Path: - candidate = repo_root.resolve() / "src-new" + candidate = repo_root.resolve() / "src" if (candidate / "astrbot_sdk").exists(): return candidate return Path(__file__).resolve().parents[2] diff --git a/astrbot_sdk/runtime/transport.py b/src/astrbot_sdk/runtime/transport.py similarity index 100% rename from astrbot_sdk/runtime/transport.py rename to src/astrbot_sdk/runtime/transport.py diff --git a/astrbot_sdk/runtime/worker.py b/src/astrbot_sdk/runtime/worker.py similarity index 100% rename from astrbot_sdk/runtime/worker.py rename to src/astrbot_sdk/runtime/worker.py diff --git a/astrbot_sdk/schedule.py b/src/astrbot_sdk/schedule.py similarity index 100% rename from astrbot_sdk/schedule.py rename to src/astrbot_sdk/schedule.py diff --git a/astrbot_sdk/session_waiter.py b/src/astrbot_sdk/session_waiter.py similarity index 100% rename from astrbot_sdk/session_waiter.py rename to src/astrbot_sdk/session_waiter.py diff --git a/astrbot_sdk/star.py b/src/astrbot_sdk/star.py similarity index 100% rename from astrbot_sdk/star.py rename to src/astrbot_sdk/star.py diff --git a/astrbot_sdk/star_tools.py b/src/astrbot_sdk/star_tools.py similarity index 100% rename from astrbot_sdk/star_tools.py rename to src/astrbot_sdk/star_tools.py diff --git a/astrbot_sdk/testing.py b/src/astrbot_sdk/testing.py similarity index 100% rename from astrbot_sdk/testing.py rename to src/astrbot_sdk/testing.py diff --git a/astrbot_sdk/types.py b/src/astrbot_sdk/types.py similarity index 100% rename from astrbot_sdk/types.py rename to src/astrbot_sdk/types.py diff --git a/tests_v4/README.md b/tests_v4/README.md deleted file mode 100644 index fe717e2e8..000000000 --- a/tests_v4/README.md +++ /dev/null @@ -1,172 +0,0 @@ -# AstrBot SDK Tests - -## Overview - -当前测试集使用 `pytest` + `pytest-asyncio`,覆盖 v4 原生协议、运行时、客户端和本地开发入口。 - -## Test Structure - -``` -tests_v4/ -├── conftest.py # 共享 fixtures 和路径引导 -├── helpers.py # 内存传输等测试辅助 -├── test_api_decorators.py # 装饰器元数据与 API 入口 -├── test_capability_proxy.py # CapabilityProxy 调用与校验 -├── test_capability_router.py # 内建 capability 与 schema 验证 -├── test_clients_module.py # clients 包导出 -├── test_conftest_fixtures.py # conftest fixtures 行为 -├── test_context.py # Context 与 CancelToken -├── test_db_client.py # DBClient -├── test_decorators.py # 顶层 decorators 模块 -├── test_entrypoints.py # 已安装环境下的 CLI 入口 -├── test_events.py # MessageEvent -├── test_handler_dispatcher.py # handler/capability 参数注入与分发 -├── test_http_metadata_clients.py # HTTPClient 与 MetadataClient -├── test_llm_client.py # LLMClient -├── test_memory_client.py # MemoryClient -├── test_peer.py # Peer 握手、调用、取消、连接失败 -├── test_platform_client.py # PlatformClient -├── test_protocol.py # 协议级冒烟测试 -├── test_protocol_descriptors.py # 描述符与 schema 模型 -├── test_protocol_messages.py # 协议消息模型 -├── test_testing_module.py # 本地 harness / testing 入口 -└── test_transport.py # stdio / websocket transport -``` - -## 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]" -``` - -## Coverage Focus - -- 协议层:`test_protocol_messages.py`、`test_protocol_descriptors.py`、`test_peer.py` -- 运行时调度:`test_capability_router.py`、`test_handler_dispatcher.py` -- 客户端 facade:`test_llm_client.py`、`test_db_client.py`、`test_memory_client.py`、`test_platform_client.py`、`test_http_metadata_clients.py` -- 本地开发入口:`test_testing_module.py`、`test_entrypoints.py` diff --git a/tests_v4/__init__.py b/tests_v4/__init__.py deleted file mode 100644 index c9c2ef67b..000000000 --- a/tests_v4/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__all__: list[str] = [] diff --git a/tests_v4/conftest.py b/tests_v4/conftest.py deleted file mode 100644 index c7bb47aa2..000000000 --- a/tests_v4/conftest.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Pytest共享的fixture和测试引导辅助函数。""" - -# ruff: noqa: E402 # 忽略E402(模块导入顺序)警告 - -# 测试配置 -import asyncio -import sys -import tempfile -import textwrap -from collections.abc import Generator -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import pytest - -# 将src-new目录添加到Python路径 - 这使得测试可以运行,但不算作"已安装"的包 -SRC_NEW_PATH = str(Path(__file__).parent.parent / "src-new") -sys.path.insert(0, SRC_NEW_PATH) - - -# ============================================================ -# 异步测试配置 -# ============================================================ - - -@pytest.fixture(scope="session") -def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: - """为异步测试创建事件循环。 - - 这是一个会话级别的fixture,在整个测试会话期间只创建一次事件循环。 - """ - policy = asyncio.get_event_loop_policy() # 获取当前事件循环策略 - loop = policy.new_event_loop() # 创建新的事件循环 - yield loop # 提供事件循环给测试使用 - loop.close() # 测试结束后关闭事件循环 - - -# ============================================================ -# 传输层Fixture(用于模拟网络通信) -# ============================================================ - - -class MemoryTransport: - """用于测试对等通信的内存传输模拟。 - - 这个类模拟了两个对等方之间的通信通道,所有消息都在内存中传递, - 无需实际网络连接。 - """ - - def __init__(self) -> None: - self._closed = asyncio.Event() # 用于跟踪传输是否已关闭 - self._message_handler = None # 消息处理函数 - self.partner: MemoryTransport | None = None # 通信伙伴 - - def set_message_handler(self, handler) -> None: - """设置消息处理函数。 - - Args: - handler: 接收消息的异步函数 - """ - 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: - """发送消息给伙伴。 - - Args: - payload: 要发送的消息内容 - - Raises: - RuntimeError: 如果没有设置伙伴传输 - """ - 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: - """内部方法:将消息分发给本地处理函数。 - - Args: - payload: 接收到的消息内容 - """ - if self._message_handler is not None: - await self._message_handler(payload) - - -@pytest.fixture -def transport_pair() -> tuple[MemoryTransport, MemoryTransport]: - """创建一对相互连接的内存传输实例。 - - 返回的左右两个传输实例互为伙伴,可用于模拟两个对等方之间的通信。 - - Returns: - (left_transport, right_transport) 的元组 - """ - left = MemoryTransport() - right = MemoryTransport() - left.partner = right # 左传输的伙伴是右传输 - right.partner = left # 右传输的伙伴是左传输 - return left, right - - -# ============================================================ -# 模拟/Fake Fixture(用于测试的假对象) -# ============================================================ - - -class FakeEnvManager: - """用于测试的虚假环境管理器。 - - 模拟真实的环境管理器行为,但不执行实际的环境准备操作。 - """ - - def plan(self, plugins: list[Any]): - return SimpleNamespace( - groups=[], - plugins=list(plugins), - plugin_to_group={}, - skipped_plugins={}, - ) - - def prepare_environment(self, _plugin: Any) -> Path: - """模拟准备插件环境。 - - Args: - _plugin: 插件对象(未使用) - - Returns: - 返回当前Python解释器路径作为模拟的环境路径 - """ - return Path(sys.executable) - - -@pytest.fixture -def fake_env_manager() -> FakeEnvManager: - """提供一个虚假的环境管理器fixture。""" - return FakeEnvManager() - - -# 导入需要使用的类型 -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: - """创建一个配置了默认处理函数的核心对等方。 - - 这个fixture创建并启动一个核心角色的对等方,设置了初始化和调用处理函数。 - - Args: - transport_pair: 传输对fixture - - Returns: - 已启动的核心对等方实例 - """ - 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, # 请求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: - """创建一个连接到核心的插件对等方。 - - 这个fixture创建并启动一个插件角色的对等方。 - - Args: - transport_pair: 传输对fixture - - Returns: - 已启动的插件对等方实例 - """ - _, right = transport_pair # 使用传输对中的右传输 - - # 创建插件对等方 - peer = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await peer.start() # 启动对等方 - yield peer - await peer.stop() # 测试结束后停止 - - -@pytest.fixture -def temp_plugin_dir() -> Generator[Path, None, None]: - """创建用于插件测试的临时目录。 - - 这个fixture创建一个临时目录,并在测试结束后自动清理。 - - Yields: - 临时目录的Path对象 - """ - with tempfile.TemporaryDirectory() as temp_dir: - yield Path(temp_dir) - - -def create_test_plugin(plugin_root: Path, name: str = "test_plugin") -> None: - """辅助函数:创建一个最小的测试插件。 - - 在指定目录创建插件所需的基本文件结构: - - commands/__init__.py - - commands/sample.py(包含测试命令) - - requirements.txt(空文件) - - plugin.yaml(插件配置文件) - - Args: - plugin_root: 插件根目录 - name: 插件名称,默认为"test_plugin" - """ - # 创建commands目录和__init__.py文件 - (plugin_root / "commands").mkdir(parents=True, exist_ok=True) - (plugin_root / "commands" / "__init__.py").write_text("", encoding="utf-8") - - # 创建空的requirements.txt - (plugin_root / "requirements.txt").write_text("", encoding="utf-8") - - # 创建插件配置文件plugin.yaml - (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}" # 使用当前Python版本 - components: - - class: commands.sample:TestPlugin - type: command - name: test - description: test command - """ - ), - encoding="utf-8", - ) - - # 创建测试命令文件sample.py - (plugin_root / "commands" / "sample.py").write_text( - textwrap.dedent( - """\ - from astrbot_sdk import Context, MessageEvent, Star, on_command - - - class TestPlugin(Star): - @on_command("test") # 注册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: - """创建一个测试插件并返回其根目录。 - - 这个fixture使用create_test_plugin函数创建插件,并返回插件目录路径。 - - Args: - temp_plugin_dir: 临时插件目录fixture - - Returns: - 包含插件的目录路径 - """ - plugin_root = temp_plugin_dir / "plugins" / "test_plugin" - create_test_plugin(plugin_root) # 创建测试插件 - return temp_plugin_dir / "plugins" diff --git a/tests_v4/helpers.py b/tests_v4/helpers.py deleted file mode 100644 index 05bf8f0df..000000000 --- a/tests_v4/helpers.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations # 启用延迟类型注解求值,避免循环引用 - -import asyncio -import shutil -from types import SimpleNamespace -from pathlib import Path -from typing import cast - -from astrbot_sdk.runtime.transport import RawPayload, Transport - - -class MemoryTransport(Transport): - """基于内存的传输层实现,用于测试场景。 - - 继承自Transport基类,模拟两个对等方之间的通信,所有消息在内存中传递, - 无需实际网络连接。主要用于单元测试。 - """ - - def __init__(self) -> None: - """初始化内存传输实例。""" - super().__init__() # 调用父类初始化方法 - self.partner: "MemoryTransport | None" = ( - None # 通信伙伴,可以是对等的另一个MemoryTransport实例 - ) - - async def start(self) -> None: - """启动传输。 - - 通过清除关闭标志使传输变为可用状态。 - """ - self._closed.clear() # 清除关闭事件 - - async def stop(self) -> None: - """停止传输。 - - 设置关闭标志,表示传输已停止。 - """ - self._closed.set() # 设置关闭事件 - - async def send(self, payload: RawPayload) -> None: - """发送消息给伙伴。 - - Args: - payload: 要发送的消息内容字符串 - - Raises: - RuntimeError: 如果没有设置伙伴传输(即self.partner为None) - """ - if self.partner is None: - raise RuntimeError("MemoryTransport 未连接 partner") - # 将消息转发给伙伴的_dispatch方法进行处理 - if isinstance(payload, str): - payload = payload.encode("utf-8") - await self.partner._dispatch(cast(bytes, payload)) - - -def make_transport_pair() -> tuple[MemoryTransport, MemoryTransport]: - """创建一对相互连接的内存传输实例。 - - 工厂函数,用于创建两个互为通信伙伴的MemoryTransport实例, - 简化测试设置过程。 - - Returns: - tuple[MemoryTransport, MemoryTransport]: 返回左右两个传输实例的元组, - 它们已互相设置为伙伴关系 - """ - left = MemoryTransport() # 创建左侧传输实例 - right = MemoryTransport() # 创建右侧传输实例 - left.partner = right # 设置左侧的伙伴为右侧 - right.partner = left # 设置右侧的伙伴为左侧 - return left, right # 返回配对的传输实例 - - -class FakeEnvManager: - """虚假的环境管理器,用于测试。 - - 模拟真实环境管理器的行为,但不执行实际的环境准备操作, - 主要用于需要环境管理器但又不希望产生副作用的测试场景。 - """ - - def plan(self, plugins): - return SimpleNamespace( - groups=[], - plugins=list(plugins), - plugin_to_group={}, - skipped_plugins={}, - ) - - def prepare_environment(self, _plugin) -> Path: - """模拟准备插件环境的方法。 - - 不实际创建虚拟环境,而是返回当前Python解释器路径作为模拟的环境路径。 - - Args: - _plugin: 插件对象参数,在模拟实现中未使用 - - Returns: - Path: 当前Python解释器的路径 - """ - # 动态导入sys模块并返回可执行文件路径 - return Path(__import__("sys").executable) - - -async def drain_loop() -> None: - """清空事件循环的辅助函数。 - - 通过短暂的异步睡眠,让事件循环有机会处理所有已排队的任务和回调。 - 常用于测试中等待异步操作完成,确保所有待处理的事件被处理。 - - 睡眠时间(0.05秒)足够短不会明显减慢测试,又足够长让事件循环有机会处理任务。 - """ - await asyncio.sleep(0.05) # 暂停当前协程50毫秒,让出控制权给事件循环 - - -def sample_plugin_dir(name: str) -> Path: - return Path(__file__).resolve().parents[1] / "test_plugin" / name - - -def copy_sample_plugin(name: str, destination: Path) -> Path: - shutil.copytree( - sample_plugin_dir(name), - destination, - ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), - ) - return destination diff --git a/tests_v4/test_api_decorators.py b/tests_v4/test_api_decorators.py deleted file mode 100644 index cd220ef11..000000000 --- a/tests_v4/test_api_decorators.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -Unit tests for API decorators and Star class. -""" - -from __future__ import annotations - -import asyncio - -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 diff --git a/tests_v4/test_capability_proxy.py b/tests_v4/test_capability_proxy.py deleted file mode 100644 index ba3f22937..000000000 --- a/tests_v4/test_capability_proxy.py +++ /dev/null @@ -1,323 +0,0 @@ -""" -Tests for clients/_proxy.py - CapabilityProxy implementation. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients._proxy import CapabilityProxy -from astrbot_sdk.errors import AstrBotError - - -@dataclass -class MockCapabilityDescriptor: - """Mock capability descriptor for testing.""" - - name: str - supports_stream: bool | None = None - - -class MockPeer: - """Mock peer for testing CapabilityProxy.""" - - def __init__(self): - self.remote_capability_map: dict[str, MockCapabilityDescriptor] = {} - self.remote_peer = None - self.invoke = AsyncMock(return_value={"result": "ok"}) - self.invoke_stream = AsyncMock() - - -class TestCapabilityProxyInit: - """Tests for CapabilityProxy initialization.""" - - def test_init_with_peer(self): - """CapabilityProxy should store peer reference.""" - peer = MagicMock() - proxy = CapabilityProxy(peer) - assert proxy._peer is peer - - -class TestCapabilityProxyGetDescriptor: - """Tests for CapabilityProxy._get_descriptor() method.""" - - def test_get_descriptor_returns_descriptor(self): - """_get_descriptor should return descriptor if found.""" - peer = MagicMock() - peer.remote_capability_map = {"db.get": MockCapabilityDescriptor(name="db.get")} - proxy = CapabilityProxy(peer) - - result = proxy._get_descriptor("db.get") - assert result is not None - assert result.name == "db.get" - - def test_get_descriptor_returns_none_for_missing(self): - """_get_descriptor should return None if not found.""" - peer = MagicMock() - peer.remote_capability_map = {} - proxy = CapabilityProxy(peer) - - result = proxy._get_descriptor("nonexistent") - assert result is None - - def test_get_descriptor_with_empty_map(self): - """_get_descriptor should work with empty capability map.""" - peer = MagicMock() - peer.remote_capability_map = {} - proxy = CapabilityProxy(peer) - - result = proxy._get_descriptor("anything") - assert result is None - - -class TestCapabilityProxyEnsureAvailable: - """Tests for CapabilityProxy._ensure_available() method.""" - - def test_ensure_available_passes_when_descriptor_exists(self): - """_ensure_available should pass when descriptor exists.""" - peer = MagicMock() - peer.remote_capability_map = { - "test.cap": MockCapabilityDescriptor(name="test.cap") - } - proxy = CapabilityProxy(peer) - - # Should not raise - proxy._ensure_available("test.cap", stream=False) - - def test_ensure_available_raises_capability_not_found(self): - """_ensure_available should raise capability_not_found when missing.""" - peer = MagicMock() - peer.remote_capability_map = { - "other.cap": MockCapabilityDescriptor(name="other.cap") - } - proxy = CapabilityProxy(peer) - - with pytest.raises(AstrBotError) as exc_info: - proxy._ensure_available("missing.cap", stream=False) - - assert exc_info.value.code == "capability_not_found" - assert "missing.cap" in exc_info.value.message - - def test_ensure_available_passes_when_map_empty(self): - """_ensure_available should pass (return None) when capability map is empty.""" - peer = MagicMock() - peer.remote_capability_map = {} - peer.remote_peer = None - proxy = CapabilityProxy(peer) - - # Should not raise when map is empty - proxy._ensure_available("any.cap", stream=False) - - def test_ensure_available_raises_when_remote_initialized_without_capability(self): - """空 capability 表在远端已初始化后应视为真实缺失。""" - peer = MagicMock() - peer.remote_capability_map = {} - peer.remote_peer = object() - proxy = CapabilityProxy(peer) - - with pytest.raises(AstrBotError) as exc_info: - proxy._ensure_available("missing.cap", stream=False) - - assert exc_info.value.code == "capability_not_found" - - def test_ensure_available_raises_for_stream_not_supported(self): - """_ensure_available should raise when stream requested but not supported.""" - peer = MagicMock() - peer.remote_capability_map = { - "test.cap": MockCapabilityDescriptor(name="test.cap", supports_stream=False) - } - proxy = CapabilityProxy(peer) - - with pytest.raises(AstrBotError) as exc_info: - proxy._ensure_available("test.cap", stream=True) - - assert exc_info.value.code == "invalid_input" - assert "不支持 stream=true" in exc_info.value.message - - def test_ensure_available_passes_for_stream_supported(self): - """_ensure_available should pass when stream is supported.""" - peer = MagicMock() - peer.remote_capability_map = { - "test.cap": MockCapabilityDescriptor(name="test.cap", supports_stream=True) - } - proxy = CapabilityProxy(peer) - - # Should not raise - proxy._ensure_available("test.cap", stream=True) - - def test_ensure_available_handles_none_supports_stream(self): - """_ensure_available should treat None supports_stream as not supporting stream.""" - peer = MagicMock() - peer.remote_capability_map = { - "test.cap": MockCapabilityDescriptor(name="test.cap", supports_stream=None) - } - proxy = CapabilityProxy(peer) - - # Should not raise for non-stream - proxy._ensure_available("test.cap", stream=False) - - # Should raise for stream=True when supports_stream is None - with pytest.raises(AstrBotError) as exc_info: - proxy._ensure_available("test.cap", stream=True) - assert exc_info.value.code == "invalid_input" - - -class TestCapabilityProxyCall: - """Tests for CapabilityProxy.call() method.""" - - @pytest.mark.asyncio - async def test_call_invokes_peer(self): - """call() should invoke peer with correct parameters.""" - peer = MockPeer() - peer.remote_capability_map = {"db.get": MockCapabilityDescriptor(name="db.get")} - proxy = CapabilityProxy(peer) - - result = await proxy.call("db.get", {"key": "test"}) - - peer.invoke.assert_called_once_with("db.get", {"key": "test"}, stream=False) - assert result == {"result": "ok"} - - @pytest.mark.asyncio - async def test_call_without_capability_map(self): - """call() should work when capability map is empty.""" - peer = MockPeer() - peer.remote_capability_map = {} - proxy = CapabilityProxy(peer) - - result = await proxy.call("any.cap", {}) - - peer.invoke.assert_called_once_with("any.cap", {}, stream=False) - assert result == {"result": "ok"} - - @pytest.mark.asyncio - async def test_call_raises_for_missing_capability(self): - """call() should raise for missing capability when map is not empty.""" - peer = MockPeer() - peer.remote_capability_map = { - "other.cap": MockCapabilityDescriptor(name="other.cap") - } - proxy = CapabilityProxy(peer) - - with pytest.raises(AstrBotError) as exc_info: - await proxy.call("missing.cap", {}) - - assert exc_info.value.code == "capability_not_found" - - -class MockAsyncIterator: - """Mock async iterator for testing stream responses.""" - - def __init__(self, items): - self._items = list(items) - self._index = 0 - - def __aiter__(self): - return self - - async def __anext__(self): - if self._index >= len(self._items): - raise StopAsyncIteration - item = self._items[self._index] - self._index += 1 - return item - - -@dataclass -class MockEvent: - """Mock stream event for testing.""" - - phase: str - data: dict - - -class TestCapabilityProxyStream: - """Tests for CapabilityProxy.stream() method.""" - - @pytest.mark.asyncio - async def test_stream_yields_delta_data(self): - """stream() should yield data from delta events.""" - peer = MockPeer() - - # invoke_stream is an async method that returns AsyncIterator - events = [ - MockEvent(phase="delta", data={"text": "chunk1"}), - MockEvent(phase="delta", data={"text": "chunk2"}), - MockEvent(phase="complete", data={"done": True}), - ] - peer.invoke_stream = AsyncMock(return_value=MockAsyncIterator(events)) - peer.remote_capability_map = { - "llm.stream": MockCapabilityDescriptor( - name="llm.stream", supports_stream=True - ) - } - proxy = CapabilityProxy(peer) - - chunks = [] - async for data in proxy.stream("llm.stream", {"prompt": "hi"}): - chunks.append(data) - - assert len(chunks) == 2 - assert chunks[0] == {"text": "chunk1"} - assert chunks[1] == {"text": "chunk2"} - - @pytest.mark.asyncio - async def test_stream_filters_non_delta_events(self): - """stream() should only yield delta events.""" - peer = MockPeer() - - events = [ - MockEvent(phase="start", data={"session": "abc"}), - MockEvent(phase="delta", data={"text": "hello"}), - MockEvent(phase="complete", data={}), - MockEvent(phase="delta", data={"text": "world"}), - ] - peer.invoke_stream = AsyncMock(return_value=MockAsyncIterator(events)) - peer.remote_capability_map = { - "test.stream": MockCapabilityDescriptor( - name="test.stream", supports_stream=True - ) - } - proxy = CapabilityProxy(peer) - - chunks = [] - async for data in proxy.stream("test.stream", {}): - chunks.append(data) - - # Only delta events should be yielded - assert len(chunks) == 2 - assert chunks[0] == {"text": "hello"} - assert chunks[1] == {"text": "world"} - - @pytest.mark.asyncio - async def test_stream_raises_for_non_streaming_capability(self): - """stream() should raise when capability doesn't support streaming.""" - peer = MockPeer() - peer.remote_capability_map = { - "db.get": MockCapabilityDescriptor(name="db.get", supports_stream=False) - } - proxy = CapabilityProxy(peer) - - with pytest.raises(AstrBotError) as exc_info: - async for _ in proxy.stream("db.get", {}): - pass - - assert exc_info.value.code == "invalid_input" - - @pytest.mark.asyncio - async def test_stream_works_without_capability_map(self): - """stream() should work when capability map is empty.""" - peer = MockPeer() - - events = [MockEvent(phase="delta", data={"text": "ok"})] - peer.invoke_stream = AsyncMock(return_value=MockAsyncIterator(events)) - peer.remote_capability_map = {} - proxy = CapabilityProxy(peer) - - chunks = [] - async for data in proxy.stream("any.stream", {}): - chunks.append(data) - - assert chunks == [{"text": "ok"}] diff --git a/tests_v4/test_capability_router.py b/tests_v4/test_capability_router.py deleted file mode 100644 index 13af46bf8..000000000 --- a/tests_v4/test_capability_router.py +++ /dev/null @@ -1,1236 +0,0 @@ -""" -Tests for runtime/capability_router.py - CapabilityRouter implementation. -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock - -import pytest - -from astrbot_sdk._invocation_context import caller_plugin_scope -from astrbot_sdk.context import CancelToken -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.protocol.descriptors import ( - BUILTIN_CAPABILITY_SCHEMAS, - CapabilityDescriptor, -) -from astrbot_sdk.runtime.capability_router import ( - CAPABILITY_NAME_PATTERN, - RESERVED_CAPABILITY_PREFIXES, - StreamExecution, - _CapabilityRegistration, -) -from astrbot_sdk.runtime.capability_router import CapabilityRouter - - -class TestStreamExecution: - """Tests for StreamExecution dataclass.""" - - def test_init(self): - """StreamExecution should store iterator and finalize.""" - - async def gen(): - yield {"text": "a"} - - def fin(chunks): - return {"count": len(chunks)} - - execution = StreamExecution(iterator=gen(), finalize=fin) - assert execution.iterator is not None - assert execution.finalize is fin - - -class TestCapabilityRegistration: - """Tests for _CapabilityRegistration dataclass.""" - - def test_init(self): - """_CapabilityRegistration should store all fields.""" - descriptor = CapabilityDescriptor(name="test.cap", description="Test") - call_handler = AsyncMock() - - reg = _CapabilityRegistration( - descriptor=descriptor, - call_handler=call_handler, - exposed=True, - ) - - assert reg.descriptor == descriptor - assert reg.call_handler == call_handler - assert reg.stream_handler is None - assert reg.finalize is None - assert reg.exposed is True - - def test_defaults(self): - """_CapabilityRegistration should have correct defaults.""" - descriptor = CapabilityDescriptor(name="test.cap", description="Test") - - reg = _CapabilityRegistration(descriptor=descriptor) - - assert reg.call_handler is None - assert reg.stream_handler is None - assert reg.finalize is None - assert reg.exposed is True - - -class TestCapabilityNamePattern: - """Tests for capability name validation pattern.""" - - def test_valid_names(self): - """Valid capability names should match pattern.""" - valid_names = [ - "llm.chat", - "db.get", - "memory.search", - "platform.send", - "a.b", - "my_module.my_method", - "ns123.method456", - ] - for name in valid_names: - assert CAPABILITY_NAME_PATTERN.fullmatch(name), f"{name} should be valid" - - def test_invalid_names(self): - """Invalid capability names should not match pattern.""" - invalid_names = [ - "llm", # No dot - "LLM.chat", # Uppercase - "llm.Chat", # Uppercase method - "llm.chat.extra", # Too many parts - "1llm.chat", # Starts with number - "llm.1chat", # Method starts with number - ".chat", # Empty namespace - "llm.", # Empty method - "llm-chat", # Hyphen instead of dot - ] - for name in invalid_names: - assert not CAPABILITY_NAME_PATTERN.fullmatch(name), ( - f"{name} should be invalid" - ) - - -class TestReservedCapabilityPrefixes: - """Tests for reserved capability prefixes.""" - - def test_reserved_prefixes(self): - """Reserved prefixes should be defined.""" - assert "handler." in RESERVED_CAPABILITY_PREFIXES - assert "system." in RESERVED_CAPABILITY_PREFIXES - assert "internal." in RESERVED_CAPABILITY_PREFIXES - - def test_reserved_names_are_detected(self): - """Reserved names should be detected by startswith.""" - reserved_names = [ - "handler.demo", - "system.health", - "internal.trace", - ] - for name in reserved_names: - assert any( - name.startswith(prefix) for prefix in RESERVED_CAPABILITY_PREFIXES - ), f"{name} should be reserved" - - -class TestCapabilityRouterInit: - """Tests for CapabilityRouter initialization.""" - - def test_init_creates_empty_stores(self): - """CapabilityRouter should start with empty stores.""" - router = CapabilityRouter() - # _registrations 会有内置 capabilities,但 stores 应该为空 - assert router.db_store == {} - assert router.memory_store == {} - assert router.sent_messages == [] - - def test_init_registers_builtin_capabilities(self): - """CapabilityRouter should register built-in capabilities on init.""" - router = CapabilityRouter() - descriptors = router.descriptors() - - capability_names = [d.name for d in descriptors] - - # LLM capabilities - assert "llm.chat" in capability_names - assert "llm.chat_raw" in capability_names - assert "llm.stream_chat" in capability_names - - # Memory capabilities - assert "memory.search" in capability_names - assert "memory.save" in capability_names - assert "memory.get" in capability_names - assert "memory.delete" in capability_names - - # DB capabilities - assert "db.get" in capability_names - assert "db.set" in capability_names - assert "db.delete" in capability_names - assert "db.list" in capability_names - assert "db.get_many" in capability_names - assert "db.set_many" in capability_names - assert "db.watch" in capability_names - - # Platform capabilities - assert "platform.send" in capability_names - assert "platform.send_image" in capability_names - assert "platform.send_chain" in capability_names - assert "platform.get_members" in capability_names - - # HTTP capabilities - assert "http.register_api" in capability_names - assert "http.unregister_api" in capability_names - assert "http.list_apis" in capability_names - - # Metadata capabilities - assert "metadata.get_plugin" in capability_names - assert "metadata.list_plugins" in capability_names - assert "metadata.get_plugin_config" in capability_names - - def test_builtin_descriptors_use_protocol_schema_registry(self): - """CapabilityRouter should source built-in schemas from protocol constants.""" - router = CapabilityRouter() - descriptors = { - descriptor.name: descriptor for descriptor in router.descriptors() - } - - for name, schema in BUILTIN_CAPABILITY_SCHEMAS.items(): - assert descriptors[name].input_schema == schema["input"] - assert descriptors[name].output_schema == schema["output"] - - def test_db_watch_descriptor_supports_stream(self): - router = CapabilityRouter() - descriptors = { - descriptor.name: descriptor for descriptor in router.descriptors() - } - - assert descriptors["db.watch"].supports_stream is True - - -class TestCapabilityRouterRegister: - """Tests for CapabilityRouter.register method.""" - - def test_register_adds_capability(self): - """register should add capability to registrations.""" - router = CapabilityRouter() - descriptor = CapabilityDescriptor(name="test.cap", description="Test") - - router.register(descriptor) - - assert "test.cap" in router._registrations - assert router._registrations["test.cap"].descriptor == descriptor - - def test_register_with_handlers(self): - """register should store handlers.""" - router = CapabilityRouter() - descriptor = CapabilityDescriptor(name="test.cap", description="Test") - - async def call_handler(req_id, payload, token): - return {"result": "ok"} - - async def stream_handler(req_id, payload, token): - yield {"chunk": 1} - - def finalize(chunks): - return {"count": len(chunks)} - - router.register( - descriptor, - call_handler=call_handler, - stream_handler=stream_handler, - finalize=finalize, - ) - - reg = router._registrations["test.cap"] - assert reg.call_handler == call_handler - assert reg.stream_handler == stream_handler - assert reg.finalize == finalize - - def test_register_invalid_name_raises(self): - """register should reject invalid capability names.""" - router = CapabilityRouter() - - with pytest.raises(ValueError, match="capability 名称必须匹配"): - router.register(CapabilityDescriptor(name="invalid", description="Bad")) - - def test_register_reserved_name_raises(self): - """register should reject reserved names for exposed registrations.""" - router = CapabilityRouter() - - with pytest.raises(ValueError, match="保留 capability"): - router.register( - CapabilityDescriptor(name="handler.demo", description="Reserved") - ) - - def test_register_reserved_name_allowed_for_internal(self): - """register should allow reserved names for internal (exposed=False).""" - router = CapabilityRouter() - - # Should not raise - router.register( - CapabilityDescriptor(name="handler.internal", description="Internal"), - exposed=False, - ) - - # Should not appear in descriptors - names = [d.name for d in router.descriptors()] - assert "handler.internal" not in names - - def test_descriptors_only_returns_exposed(self): - """descriptors should only return exposed capabilities.""" - router = CapabilityRouter() - - router.register( - CapabilityDescriptor(name="exposed.cap", description="Exposed"), - exposed=True, - ) - router.register( - CapabilityDescriptor(name="hidden.cap", description="Hidden"), - exposed=False, - ) - - names = [d.name for d in router.descriptors()] - assert "exposed.cap" in names - assert "hidden.cap" not in names - - -class TestCapabilityRouterExecute: - """Tests for CapabilityRouter.execute method.""" - - @pytest.mark.asyncio - async def test_execute_calls_handler(self): - """execute should call the registered handler.""" - router = CapabilityRouter() - router.register( - CapabilityDescriptor(name="test.cap", description="Test"), - call_handler=AsyncMock(return_value={"result": "ok"}), - ) - - token = CancelToken() - result = await router.execute( - "test.cap", - {}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert result == {"result": "ok"} - - @pytest.mark.asyncio - async def test_execute_validates_input_schema(self): - """execute should validate input against schema.""" - router = CapabilityRouter() - router.register( - CapabilityDescriptor( - name="test.cap", - description="Test", - input_schema={ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"], - }, - ), - call_handler=AsyncMock(return_value={}), - ) - - token = CancelToken() - - # Missing required field - with pytest.raises(AstrBotError) as raised: - await router.execute( - "test.cap", - {}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - message = str(raised.value) - assert "capability 'test.cap' 的输入校验失败" in message - assert "缺少必填字段:name" in message - - @pytest.mark.asyncio - async def test_execute_validates_output_schema(self): - """execute should validate output against schema.""" - router = CapabilityRouter() - router.register( - CapabilityDescriptor( - name="test.cap", - description="Test", - output_schema={ - "type": "object", - "properties": {"result": {"type": "string"}}, - "required": ["result"], - }, - ), - call_handler=AsyncMock(return_value={}), # Missing required field - ) - - token = CancelToken() - - with pytest.raises(AstrBotError) as raised: - await router.execute( - "test.cap", - {}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - message = str(raised.value) - assert "capability 'test.cap' 的输出校验失败" in message - assert "缺少必填字段:result" in message - - -class TestCapabilityRouterDBWatch: - """Router-level tests for db.watch behavior.""" - - @pytest.mark.asyncio - async def test_db_watch_receives_set_and_delete_events(self): - router = CapabilityRouter() - token = CancelToken() - - execution = await router.execute( - "db.watch", - {"prefix": None}, - stream=True, - cancel_token=token, - request_id="watch-1", - ) - assert isinstance(execution, StreamExecution) - assert execution.collect_chunks is False - - await router.execute( - "db.set", - {"key": "a", "value": 1}, - stream=False, - cancel_token=token, - request_id="set-1", - ) - await router.execute( - "db.delete", - {"key": "a"}, - stream=False, - cancel_token=token, - request_id="del-1", - ) - - event1 = await anext(execution.iterator) - event2 = await anext(execution.iterator) - assert event1 == {"op": "set", "key": "a", "value": 1} - assert event2 == {"op": "delete", "key": "a", "value": None} - - close = getattr(execution.iterator, "aclose", None) - if close is not None: - await close() - - @pytest.mark.asyncio - async def test_db_watch_prefix_filters_events(self): - router = CapabilityRouter() - token = CancelToken() - - execution = await router.execute( - "db.watch", - {"prefix": "user:"}, - stream=True, - cancel_token=token, - request_id="watch-2", - ) - assert isinstance(execution, StreamExecution) - - await router.execute( - "db.set", - {"key": "sys:1", "value": 1}, - stream=False, - cancel_token=token, - request_id="set-2", - ) - await router.execute( - "db.set", - {"key": "user:1", "value": {"ok": True}}, - stream=False, - cancel_token=token, - request_id="set-3", - ) - - event = await anext(execution.iterator) - assert event == {"op": "set", "key": "user:1", "value": {"ok": True}} - - close = getattr(execution.iterator, "aclose", None) - if close is not None: - await close() - - @pytest.mark.asyncio - async def test_execute_missing_capability_raises(self): - """execute should raise for unknown capability.""" - router = CapabilityRouter() - token = CancelToken() - - with pytest.raises(AstrBotError, match="未找到能力"): - await router.execute( - "unknown.cap", - {}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - @pytest.mark.asyncio - async def test_execute_stream_returns_stream_execution(self): - """execute with stream=True should return StreamExecution.""" - router = CapabilityRouter() - - async def stream_handler(req_id, payload, token): - yield {"chunk": 1} - yield {"chunk": 2} - - router.register( - CapabilityDescriptor( - name="test.stream", - description="Test", - supports_stream=True, - ), - stream_handler=stream_handler, - ) - - token = CancelToken() - result = await router.execute( - "test.stream", - {}, - stream=True, - cancel_token=token, - request_id="req-1", - ) - - assert isinstance(result, StreamExecution) - - @pytest.mark.asyncio - async def test_execute_stream_handler_can_return_stream_execution(self): - """stream_handler may return StreamExecution to preserve custom finalize output.""" - router = CapabilityRouter() - - async def stream_handler(_req_id, _payload, _token): - async def iterator(): - yield {"chunk": 1} - - return StreamExecution( - iterator=iterator(), - finalize=lambda chunks: {"count": len(chunks)}, - ) - - router.register( - CapabilityDescriptor( - name="test.stream_execution", - description="Test", - supports_stream=True, - output_schema={ - "type": "object", - "properties": {"count": {"type": "integer"}}, - "required": ["count"], - }, - ), - stream_handler=stream_handler, - ) - - token = CancelToken() - result = await router.execute( - "test.stream_execution", - {}, - stream=True, - cancel_token=token, - request_id="req-stream-execution", - ) - - chunks = [] - async for chunk in result.iterator: - chunks.append(chunk) - assert result.finalize(chunks) == {"count": 1} - - @pytest.mark.asyncio - async def test_execute_stream_validates_finalize_output_schema(self): - """completed output from a stream execution should still satisfy output_schema.""" - router = CapabilityRouter() - - async def stream_handler(_req_id, _payload, _token): - async def iterator(): - yield {"chunk": 1} - - return StreamExecution( - iterator=iterator(), - finalize=lambda _chunks: {}, - ) - - router.register( - CapabilityDescriptor( - name="test.invalid_stream_output", - description="Test", - supports_stream=True, - output_schema={ - "type": "object", - "properties": {"count": {"type": "integer"}}, - "required": ["count"], - }, - ), - stream_handler=stream_handler, - ) - - token = CancelToken() - result = await router.execute( - "test.invalid_stream_output", - {}, - stream=True, - cancel_token=token, - request_id="req-invalid-stream-output", - ) - - chunks = [] - async for chunk in result.iterator: - chunks.append(chunk) - - with pytest.raises(AstrBotError, match="缺少必填字段"): - result.finalize(chunks) - - @pytest.mark.asyncio - async def test_execute_stream_without_handler_raises(self): - """execute with stream=True and no stream_handler should raise.""" - router = CapabilityRouter() - router.register( - CapabilityDescriptor(name="test.cap", description="Test"), - call_handler=AsyncMock(return_value={}), - ) - - token = CancelToken() - - with pytest.raises(AstrBotError, match="不支持 stream=true"): - await router.execute( - "test.cap", - {}, - stream=True, - cancel_token=token, - request_id="req-1", - ) - - @pytest.mark.asyncio - async def test_execute_call_without_handler_raises(self): - """execute without stream and no call_handler should raise.""" - router = CapabilityRouter() - router.register( - CapabilityDescriptor( - name="test.stream_only", - description="Stream only", - supports_stream=True, - ), - stream_handler=AsyncMock(), - ) - - token = CancelToken() - - with pytest.raises(AstrBotError, match="只能以 stream=true 调用"): - await router.execute( - "test.stream_only", - {}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - -class TestBuiltinLlmCapabilities: - """Tests for built-in LLM capabilities.""" - - @pytest.mark.asyncio - async def test_llm_chat(self): - """llm.chat should return echo response.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "llm.chat", - {"prompt": "hello"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert result["text"] == "Echo: hello" - - @pytest.mark.asyncio - async def test_llm_chat_raw(self): - """llm.chat_raw should return full response.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "llm.chat_raw", - {"prompt": "test"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert result["text"] == "Echo: test" - assert "usage" in result - assert "finish_reason" in result - assert "tool_calls" in result - - @pytest.mark.asyncio - async def test_llm_stream_chat(self): - """llm.stream_chat should yield characters.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "llm.stream_chat", - {"prompt": "hi"}, - stream=True, - cancel_token=token, - request_id="req-1", - ) - - assert isinstance(result, StreamExecution) - - chunks = [] - async for chunk in result.iterator: - chunks.append(chunk) - - # Should yield each character - text = "".join(c.get("text", "") for c in chunks) - assert text == "Echo: hi" - - -class TestBuiltinMemoryCapabilities: - """Tests for built-in memory capabilities.""" - - @pytest.mark.asyncio - async def test_memory_save_and_get(self): - """memory.save and memory.get should work together.""" - router = CapabilityRouter() - token = CancelToken() - - # Save - await router.execute( - "memory.save", - {"key": "test_key", "value": {"data": "test_value"}}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - # Get - result = await router.execute( - "memory.get", - {"key": "test_key"}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - - assert result["value"] == {"data": "test_value"} - - @pytest.mark.asyncio - async def test_memory_save_and_search(self): - """memory.save and memory.search should work together.""" - router = CapabilityRouter() - token = CancelToken() - - await router.execute( - "memory.save", - {"key": "test_key", "value": {"data": "test_value"}}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - result = await router.execute( - "memory.search", - {"query": "test"}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - - assert len(result["items"]) == 1 - assert result["items"][0]["key"] == "test_key" - - @pytest.mark.asyncio - async def test_memory_get_missing_key(self): - """memory.get should return None for missing key.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "memory.get", - {"key": "missing"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert result["value"] is None - - @pytest.mark.asyncio - async def test_memory_delete(self): - """memory.delete should remove saved memory.""" - router = CapabilityRouter() - token = CancelToken() - - # Save - await router.execute( - "memory.save", - {"key": "to_delete", "value": {"data": "value"}}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - # Delete - await router.execute( - "memory.delete", - {"key": "to_delete"}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - - # Search should return empty - result = await router.execute( - "memory.search", - {"query": "to_delete"}, - stream=False, - cancel_token=token, - request_id="req-3", - ) - - assert len(result["items"]) == 0 - - @pytest.mark.asyncio - async def test_memory_save_invalid_value(self): - """memory.save should reject non-object value.""" - router = CapabilityRouter() - token = CancelToken() - - with pytest.raises(AstrBotError, match="value 必须是 object"): - await router.execute( - "memory.save", - {"key": "test", "value": "not_an_object"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - -class TestBuiltinDbCapabilities: - """Tests for built-in DB capabilities.""" - - @pytest.mark.asyncio - async def test_db_set_and_get(self): - """db.set and db.get should work together.""" - router = CapabilityRouter() - token = CancelToken() - - # Set - await router.execute( - "db.set", - {"key": "test_key", "value": {"data": "test_value"}}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - # Get - result = await router.execute( - "db.get", - {"key": "test_key"}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - - assert result["value"] == {"data": "test_value"} - - @pytest.mark.asyncio - async def test_db_get_missing_key(self): - """db.get should return None for missing key.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "db.get", - {"key": "nonexistent"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert result["value"] is None - - @pytest.mark.asyncio - async def test_db_delete(self): - """db.delete should remove stored value.""" - router = CapabilityRouter() - token = CancelToken() - - # Set - await router.execute( - "db.set", - {"key": "to_delete", "value": {"data": "value"}}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - # Delete - await router.execute( - "db.delete", - {"key": "to_delete"}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - - # Get should return None - result = await router.execute( - "db.get", - {"key": "to_delete"}, - stream=False, - cancel_token=token, - request_id="req-3", - ) - - assert result["value"] is None - - @pytest.mark.asyncio - async def test_db_list(self): - """db.list should return keys.""" - router = CapabilityRouter() - token = CancelToken() - - # Set multiple keys - await router.execute( - "db.set", - {"key": "prefix_a", "value": {}}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - await router.execute( - "db.set", - {"key": "prefix_b", "value": {}}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - await router.execute( - "db.set", - {"key": "other", "value": {}}, - stream=False, - cancel_token=token, - request_id="req-3", - ) - - # List all - result = await router.execute( - "db.list", - {}, - stream=False, - cancel_token=token, - request_id="req-4", - ) - - assert "prefix_a" in result["keys"] - assert "prefix_b" in result["keys"] - assert "other" in result["keys"] - - # List with prefix - result = await router.execute( - "db.list", - {"prefix": "prefix_"}, - stream=False, - cancel_token=token, - request_id="req-5", - ) - - assert "prefix_a" in result["keys"] - assert "prefix_b" in result["keys"] - assert "other" not in result["keys"] - - @pytest.mark.asyncio - async def test_db_set_scalar_value(self): - """db.set should accept scalar JSON values.""" - router = CapabilityRouter() - token = CancelToken() - - await router.execute( - "db.set", - {"key": "test", "value": True}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - result = await router.execute( - "db.get", - {"key": "test"}, - stream=False, - cancel_token=token, - request_id="req-2", - ) - - assert result["value"] is True - - -class TestBuiltinPlatformCapabilities: - """Tests for built-in platform capabilities.""" - - @pytest.mark.asyncio - async def test_platform_send(self): - """platform.send should store message.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "platform.send", - {"session": "session-1", "text": "Hello"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert "message_id" in result - assert len(router.sent_messages) == 1 - assert router.sent_messages[0]["session"] == "session-1" - assert router.sent_messages[0]["text"] == "Hello" - - @pytest.mark.asyncio - async def test_platform_send_image(self): - """platform.send_image should store image message.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "platform.send_image", - {"session": "session-1", "image_url": "http://example.com/image.png"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert "message_id" in result - assert len(router.sent_messages) == 1 - assert router.sent_messages[0]["image_url"] == "http://example.com/image.png" - - @pytest.mark.asyncio - async def test_platform_send_chain(self): - """platform.send_chain should store rich message payloads.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "platform.send_chain", - { - "session": "session-1", - "chain": [ - {"type": "Plain", "text": "Hello"}, - {"type": "Image", "file": "http://example.com/image.png"}, - ], - }, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert "message_id" in result - assert len(router.sent_messages) == 1 - assert router.sent_messages[0]["chain"][0]["text"] == "Hello" - assert ( - router.sent_messages[0]["chain"][1]["file"] - == "http://example.com/image.png" - ) - - @pytest.mark.asyncio - async def test_platform_get_members(self): - """platform.get_members should return mock members.""" - router = CapabilityRouter() - token = CancelToken() - - result = await router.execute( - "platform.get_members", - {"session": "session-1"}, - stream=False, - cancel_token=token, - request_id="req-1", - ) - - assert len(result["members"]) == 2 - assert result["members"][0]["user_id"] == "session-1:member-1" - - -class TestBuiltinHttpAndMetadataCapabilities: - """Tests for built-in HTTP and metadata capabilities.""" - - @pytest.mark.asyncio - async def test_http_register_and_list_apis(self): - router = CapabilityRouter() - token = CancelToken() - - with caller_plugin_scope("demo_plugin"): - await router.execute( - "http.register_api", - { - "route": "/demo", - "methods": ["GET", "POST"], - "handler_capability": "demo.http_handler", - "description": "demo", - }, - stream=False, - cancel_token=token, - request_id="req-http-1", - ) - - with caller_plugin_scope("demo_plugin"): - result = await router.execute( - "http.list_apis", - {}, - stream=False, - cancel_token=token, - request_id="req-http-2", - ) - - assert result["apis"] == [ - { - "route": "/demo", - "methods": ["GET", "POST"], - "handler_capability": "demo.http_handler", - "description": "demo", - "plugin_id": "demo_plugin", - } - ] - - @pytest.mark.asyncio - async def test_metadata_get_plugin_and_config(self): - router = CapabilityRouter() - token = CancelToken() - router.upsert_plugin( - metadata={ - "name": "demo_plugin", - "display_name": "Demo Plugin", - "description": "demo", - "author": "tester", - "version": "0.1.0", - "enabled": True, - }, - config={"debug": True}, - ) - - with caller_plugin_scope("demo_plugin"): - plugin_result = await router.execute( - "metadata.get_plugin", - {"name": "demo_plugin"}, - stream=False, - cancel_token=token, - request_id="req-meta-1", - ) - config_result = await router.execute( - "metadata.get_plugin_config", - {"name": "demo_plugin"}, - stream=False, - cancel_token=token, - request_id="req-meta-2", - ) - - assert plugin_result["plugin"]["display_name"] == "Demo Plugin" - assert config_result["config"] == {"debug": True} - - @pytest.mark.asyncio - async def test_metadata_get_plugin_config_rejects_other_plugin(self): - router = CapabilityRouter() - token = CancelToken() - router.upsert_plugin( - metadata={"name": "demo_plugin"}, - config={"secret": True}, - ) - - with caller_plugin_scope("other_plugin"): - result = await router.execute( - "metadata.get_plugin_config", - {"name": "demo_plugin"}, - stream=False, - cancel_token=token, - request_id="req-meta-3", - ) - - assert result == {"config": None} - - -class TestValidateSchema: - """Tests for _validate_schema method.""" - - @pytest.mark.asyncio - async def test_none_schema_passes(self): - """_validate_schema with None schema should pass.""" - router = CapabilityRouter() - - # Should not raise - router._validate_schema(None, {"any": "data"}) - - @pytest.mark.asyncio - async def test_non_object_payload_raises(self): - """_validate_schema should reject non-object when schema type is object.""" - router = CapabilityRouter() - - with pytest.raises(AstrBotError, match="输入必须是 object"): - router._validate_schema({"type": "object"}, "not an object") - - @pytest.mark.asyncio - async def test_missing_required_field_raises(self): - """_validate_schema should reject missing required fields.""" - router = CapabilityRouter() - - with pytest.raises(AstrBotError, match="缺少必填字段"): - router._validate_schema( - {"type": "object", "required": ["name"]}, - {}, - ) - - @pytest.mark.asyncio - async def test_none_required_field_raises(self): - """_validate_schema should reject None required fields.""" - router = CapabilityRouter() - - with pytest.raises(AstrBotError, match="缺少必填字段"): - router._validate_schema( - {"type": "object", "required": ["name"]}, - {"name": None}, - ) - - @pytest.mark.asyncio - async def test_type_mismatch_raises(self): - """_validate_schema should reject mismatched scalar types.""" - router = CapabilityRouter() - - with pytest.raises(AstrBotError, match="字段 count 必须是 integer"): - router._validate_schema( - { - "type": "object", - "properties": {"count": {"type": "integer"}}, - "required": ["count"], - }, - {"count": "bad"}, - ) - - @pytest.mark.asyncio - async def test_array_item_type_mismatch_raises(self): - """_validate_schema should validate nested array items.""" - router = CapabilityRouter() - - with pytest.raises(AstrBotError, match=r"字段 keys\[1\] 必须是 string"): - router._validate_schema( - { - "type": "object", - "properties": { - "keys": { - "type": "array", - "items": {"type": "string"}, - } - }, - "required": ["keys"], - }, - {"keys": ["ok", 1]}, - ) diff --git a/tests_v4/test_clients_module.py b/tests_v4/test_clients_module.py deleted file mode 100644 index 2aff7a5ff..000000000 --- a/tests_v4/test_clients_module.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Tests for clients/__init__.py - Module exports. -""" - -from __future__ import annotations - - -class TestClientsModuleExports: - """Tests for clients module exports.""" - - def test_exports_db_client(self): - """clients module should export DBClient.""" - from astrbot_sdk.clients import DBClient - - assert DBClient is not None - - def test_exports_llm_client(self): - """clients module should export LLMClient.""" - from astrbot_sdk.clients import LLMClient - - assert LLMClient is not None - - def test_exports_llm_response(self): - """clients module should export LLMResponse.""" - from astrbot_sdk.clients import LLMResponse - - assert LLMResponse is not None - - def test_exports_chat_message(self): - """clients module should export ChatMessage.""" - from astrbot_sdk.clients import ChatMessage - - assert ChatMessage is not None - - def test_exports_memory_client(self): - """clients module should export MemoryClient.""" - from astrbot_sdk.clients import MemoryClient - - assert MemoryClient is not None - - def test_exports_platform_client(self): - """clients module should export PlatformClient.""" - from astrbot_sdk.clients import PlatformClient - - assert PlatformClient is not None - - def test_all_exports_defined(self): - """__all__ should contain all expected exports.""" - from astrbot_sdk.clients import __all__ - - assert "DBClient" in __all__ - assert "LLMClient" in __all__ - assert "LLMResponse" in __all__ - assert "ChatMessage" in __all__ - assert "MemoryClient" in __all__ - assert "PlatformClient" in __all__ - assert "HTTPClient" in __all__ - assert "MetadataClient" in __all__ - assert "PluginMetadata" in __all__ - - def test_exports_http_client(self): - """clients module should export HTTPClient.""" - from astrbot_sdk.clients import HTTPClient - - assert HTTPClient is not None - - def test_exports_metadata_client(self): - """clients module should export MetadataClient.""" - from astrbot_sdk.clients import MetadataClient - - assert MetadataClient is not None - - def test_exports_plugin_metadata(self): - """clients module should export PluginMetadata.""" - from astrbot_sdk.clients import PluginMetadata - - assert PluginMetadata is not None - - def test_does_not_export_capability_proxy(self): - """CapabilityProxy should not be in public exports.""" - from astrbot_sdk.clients import __all__ - - assert "CapabilityProxy" not in __all__ - - def test_capability_proxy_importable_from_private(self): - """CapabilityProxy should be importable from _proxy.""" - from astrbot_sdk.clients._proxy import CapabilityProxy - - assert CapabilityProxy is not None diff --git a/tests_v4/test_conftest_fixtures.py b/tests_v4/test_conftest_fixtures.py deleted file mode 100644 index e105b6497..000000000 --- a/tests_v4/test_conftest_fixtures.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Pytest-based tests for transport and peer communication. - -These tests demonstrate the pytest fixtures defined in conftest.py. -""" - -from __future__ import annotations - -import asyncio - -import pytest - -from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.protocol.descriptors import CapabilityDescriptor -from astrbot_sdk.protocol.messages import ( - EventMessage, - PeerInfo, - ResultMessage, -) -from astrbot_sdk.runtime.capability_router import CapabilityRouter -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.""" - - 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] diff --git a/tests_v4/test_context.py b/tests_v4/test_context.py deleted file mode 100644 index 088485fab..000000000 --- a/tests_v4/test_context.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -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_keeps_peer_reference(self, transport_pair): - """Context should retain the underlying peer for advanced diagnostics.""" - 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.peer is peer - - 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_db_client.py b/tests_v4/test_db_client.py deleted file mode 100644 index 8d15c1310..000000000 --- a/tests_v4/test_db_client.py +++ /dev/null @@ -1,339 +0,0 @@ -""" -Tests for clients/db.py - DBClient implementation. -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients.db import DBClient -from astrbot_sdk.clients._proxy import CapabilityProxy - - -class TestDBClientInit: - """Tests for DBClient initialization.""" - - def test_init_with_proxy(self): - """DBClient should store proxy reference.""" - proxy = MagicMock(spec=CapabilityProxy) - client = DBClient(proxy) - assert client._proxy is proxy - - -class TestDBClientGet: - """Tests for DBClient.get() method.""" - - @pytest.mark.asyncio - async def test_get_returns_dict_value(self): - """get() should return dict value from proxy response.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": {"data": "test"}}) - - client = DBClient(proxy) - result = await client.get("my_key") - - proxy.call.assert_called_once_with("db.get", {"key": "my_key"}) - assert result == {"data": "test"} - - @pytest.mark.asyncio - async def test_get_returns_none_for_missing_key(self): - """get() should return None when value is not found.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": None}) - - client = DBClient(proxy) - result = await client.get("missing_key") - - assert result is None - - @pytest.mark.asyncio - async def test_get_returns_scalar_value(self): - """get() should preserve non-dict scalar values.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": True}) - - client = DBClient(proxy) - result = await client.get("my_key") - - assert result is True - - @pytest.mark.asyncio - async def test_get_returns_none_when_value_key_missing(self): - """get() should return None when 'value' key is missing in response.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - result = await client.get("my_key") - - assert result is None - - @pytest.mark.asyncio - async def test_get_with_empty_key(self): - """get() should work with empty string key.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": {"empty": True}}) - - client = DBClient(proxy) - result = await client.get("") - - proxy.call.assert_called_once_with("db.get", {"key": ""}) - assert result == {"empty": True} - - -class TestDBClientSet: - """Tests for DBClient.set() method.""" - - @pytest.mark.asyncio - async def test_set_calls_proxy_with_key_and_value(self): - """set() should call proxy with correct parameters.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - await client.set("test_key", {"name": "value"}) - - proxy.call.assert_called_once_with( - "db.set", - {"key": "test_key", "value": {"name": "value"}}, - ) - - @pytest.mark.asyncio - async def test_set_with_empty_dict(self): - """set() should work with empty dict value.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - await client.set("empty", {}) - - proxy.call.assert_called_once_with("db.set", {"key": "empty", "value": {}}) - - @pytest.mark.asyncio - async def test_set_with_nested_dict(self): - """set() should work with nested dict.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - await client.set("nested", {"level1": {"level2": {"level3": "deep"}}}) - - proxy.call.assert_called_once_with( - "db.set", - {"key": "nested", "value": {"level1": {"level2": {"level3": "deep"}}}}, - ) - - @pytest.mark.asyncio - async def test_set_accepts_scalar_value(self): - """set() should accept scalar JSON values.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - client = DBClient(proxy) - - await client.set("flag", True) - - proxy.call.assert_called_once_with( - "db.set", - {"key": "flag", "value": True}, - ) - - -class TestDBClientDelete: - """Tests for DBClient.delete() method.""" - - @pytest.mark.asyncio - async def test_delete_calls_proxy_with_key(self): - """delete() should call proxy with correct key.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - await client.delete("to_delete") - - proxy.call.assert_called_once_with("db.delete", {"key": "to_delete"}) - - @pytest.mark.asyncio - async def test_delete_with_empty_key(self): - """delete() should work with empty string key.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - await client.delete("") - - proxy.call.assert_called_once_with("db.delete", {"key": ""}) - - -class TestDBClientList: - """Tests for DBClient.list() method.""" - - @pytest.mark.asyncio - async def test_list_returns_keys(self): - """list() should return list of keys.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"keys": ["key1", "key2", "key3"]}) - - client = DBClient(proxy) - result = await client.list() - - proxy.call.assert_called_once_with("db.list", {"prefix": None}) - assert result == ["key1", "key2", "key3"] - - @pytest.mark.asyncio - async def test_list_with_prefix(self): - """list() should pass prefix parameter.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"keys": ["user:1", "user:2"]}) - - client = DBClient(proxy) - result = await client.list(prefix="user:") - - proxy.call.assert_called_once_with("db.list", {"prefix": "user:"}) - assert result == ["user:1", "user:2"] - - @pytest.mark.asyncio - async def test_list_returns_empty_list_when_no_keys(self): - """list() should return empty list when no keys found.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = DBClient(proxy) - result = await client.list() - - assert result == [] - - @pytest.mark.asyncio - async def test_list_converts_non_string_items(self): - """list() should convert non-string items to string.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"keys": [123, 456]}) - - client = DBClient(proxy) - result = await client.list() - - assert result == ["123", "456"] - - @pytest.mark.asyncio - async def test_list_returns_empty_list_for_malformed_keys(self): - """list() should ignore malformed non-list key payloads.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"keys": "not-a-list"}) - - client = DBClient(proxy) - result = await client.list() - - assert result == [] - - @pytest.mark.asyncio - async def test_list_with_none_prefix(self): - """list() should handle None prefix.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"keys": []}) - - client = DBClient(proxy) - result = await client.list(prefix=None) - - proxy.call.assert_called_once_with("db.list", {"prefix": None}) - assert result == [] - - -class TestDBClientGetMany: - """Tests for DBClient.get_many() method.""" - - @pytest.mark.asyncio - async def test_get_many_returns_mapping(self): - proxy = MagicMock(spec=CapabilityProxy) - proxy.call = AsyncMock( - return_value={ - "items": [ - {"key": "a", "value": 1}, - {"key": "b", "value": None}, - ] - } - ) - client = DBClient(proxy) - - result = await client.get_many(["a", "b"]) - - proxy.call.assert_called_once_with("db.get_many", {"keys": ["a", "b"]}) - assert result == {"a": 1, "b": None} - - @pytest.mark.asyncio - async def test_get_many_returns_empty_dict_for_malformed_items(self): - proxy = MagicMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"items": "not-a-list"}) - client = DBClient(proxy) - - result = await client.get_many(["a"]) - - assert result == {} - - -class TestDBClientSetMany: - """Tests for DBClient.set_many() method.""" - - @pytest.mark.asyncio - async def test_set_many_accepts_mapping(self): - proxy = MagicMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - client = DBClient(proxy) - - await client.set_many({"a": 1, "b": 2}) - - proxy.call.assert_called_once_with( - "db.set_many", - {"items": [{"key": "a", "value": 1}, {"key": "b", "value": 2}]}, - ) - - @pytest.mark.asyncio - async def test_set_many_accepts_sequence_pairs(self): - proxy = MagicMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - client = DBClient(proxy) - - await client.set_many([("a", True), ("b", {"x": 1})]) - - proxy.call.assert_called_once_with( - "db.set_many", - {"items": [{"key": "a", "value": True}, {"key": "b", "value": {"x": 1}}]}, - ) - - -class TestDBClientWatch: - """Tests for DBClient.watch() method.""" - - @pytest.mark.asyncio - async def test_watch_calls_proxy_stream_and_yields_events(self): - async def gen(): - yield {"op": "set", "key": "a", "value": 1} - yield {"op": "delete", "key": "a", "value": None} - - proxy = MagicMock(spec=CapabilityProxy) - proxy.stream = MagicMock(return_value=gen()) - client = DBClient(proxy) - - iterator = client.watch() - - proxy.stream.assert_called_once_with("db.watch", {"prefix": None}) - events = [event async for event in iterator] - assert events == [ - {"op": "set", "key": "a", "value": 1}, - {"op": "delete", "key": "a", "value": None}, - ] - - @pytest.mark.asyncio - async def test_watch_with_prefix(self): - async def gen(): - yield {"op": "set", "key": "user:1", "value": {"ok": True}} - - proxy = MagicMock(spec=CapabilityProxy) - proxy.stream = MagicMock(return_value=gen()) - client = DBClient(proxy) - - iterator = client.watch(prefix="user:") - - proxy.stream.assert_called_once_with("db.watch", {"prefix": "user:"}) - events = [event async for event in iterator] - assert events == [{"op": "set", "key": "user:1", "value": {"ok": True}}] diff --git a/tests_v4/test_decorators.py b/tests_v4/test_decorators.py deleted file mode 100644 index 434dcc2fc..000000000 --- a/tests_v4/test_decorators.py +++ /dev/null @@ -1,425 +0,0 @@ -""" -Tests for decorators.py - Handler decorator infrastructure. -""" - -from __future__ import annotations - -import pytest -from pydantic import BaseModel - -from astrbot_sdk.decorators import ( - get_capability_meta, - HANDLER_META_ATTR, - HandlerMeta, - get_handler_meta, - on_command, - on_event, - on_message, - on_schedule, - provide_capability, - require_admin, -) -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - EventTrigger, - MessageTrigger, - Permissions, - ScheduleTrigger, -) - - -class TestHandlerMeta: - """Tests for HandlerMeta dataclass.""" - - def test_default_values(self): - """HandlerMeta should have default values.""" - meta = HandlerMeta() - assert meta.trigger is None - assert meta.priority == 0 - assert isinstance(meta.permissions, Permissions) - - def test_trigger_assignment(self): - """HandlerMeta should accept trigger assignment.""" - trigger = CommandTrigger(command="test") - meta = HandlerMeta(trigger=trigger) - assert meta.trigger is trigger - - def test_priority_assignment(self): - """HandlerMeta should accept priority assignment.""" - meta = HandlerMeta(priority=10) - assert meta.priority == 10 - - def test_permissions_assignment(self): - """HandlerMeta should accept permissions assignment.""" - permissions = Permissions(require_admin=True) - meta = HandlerMeta(permissions=permissions) - assert meta.permissions.require_admin is True - - -class TestGetHandlerMeta: - """Tests for get_handler_meta function.""" - - def test_returns_none_for_undecorated_function(self): - """get_handler_meta should return None for undecorated functions.""" - - async def plain_function(): - pass - - assert get_handler_meta(plain_function) is None - - def test_returns_meta_for_decorated_function(self): - """get_handler_meta should return meta for decorated functions.""" - - @on_command("test") - async def decorated(): - pass - - meta = get_handler_meta(decorated) - assert meta is not None - assert isinstance(meta, HandlerMeta) - - -class TestOnCommandDecorator: - """Tests for @on_command decorator.""" - - def test_sets_handler_meta_attribute(self): - """@on_command should set __astrbot_handler_meta__ attribute.""" - - @on_command("hello") - async def handler(): - pass - - assert hasattr(handler, HANDLER_META_ATTR) - - def test_creates_command_trigger(self): - """@on_command should create CommandTrigger.""" - - @on_command("hello") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert isinstance(meta.trigger, CommandTrigger) - assert meta.trigger.command == "hello" - - def test_supports_aliases(self): - """@on_command should store aliases.""" - - @on_command("hello", aliases=["hi", "hey"]) - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.aliases == ["hi", "hey"] - - def test_supports_description(self): - """@on_command should store description.""" - - @on_command("hello", description="Say hello") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.description == "Say hello" - - def test_default_empty_aliases(self): - """@on_command should default to empty aliases.""" - - @on_command("test") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.aliases == [] - - def test_preserves_function(self): - """@on_command should preserve the original function.""" - - @on_command("test") - async def handler(): - return "result" - - assert handler.__name__ == "handler" - - -class TestOnMessageDecorator: - """Tests for @on_message decorator.""" - - def test_creates_message_trigger(self): - """@on_message should create MessageTrigger.""" - - @on_message() - async def handler(): - pass - - meta = get_handler_meta(handler) - assert isinstance(meta.trigger, MessageTrigger) - - def test_supports_regex(self): - """@on_message should store regex pattern.""" - - @on_message(regex=r"\d+") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.regex == r"\d+" - - def test_supports_keywords(self): - """@on_message should store keywords.""" - - @on_message(keywords=["hello", "hi"]) - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.keywords == ["hello", "hi"] - - def test_supports_platforms(self): - """@on_message should store platforms.""" - - @on_message(platforms=["telegram", "discord"]) - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.platforms == ["telegram", "discord"] - - def test_defaults_empty_lists(self): - """@on_message should default to empty lists.""" - - @on_message() - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.keywords == [] - assert meta.trigger.platforms == [] - - -class TestOnEventDecorator: - """Tests for @on_event decorator.""" - - def test_creates_event_trigger(self): - """@on_event should create EventTrigger.""" - - @on_event("message_received") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert isinstance(meta.trigger, EventTrigger) - assert meta.trigger.event_type == "message_received" - - def test_various_event_types(self): - """@on_event should handle various event types.""" - - event_types = ["message_received", "user_joined", "custom_event"] - - for event_type in event_types: - - @on_event(event_type) - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.event_type == event_type - - -class TestOnScheduleDecorator: - """Tests for @on_schedule decorator.""" - - def test_creates_cron_trigger(self): - """@on_schedule with cron should create ScheduleTrigger.""" - - @on_schedule(cron="* * * * *") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert isinstance(meta.trigger, ScheduleTrigger) - assert meta.trigger.cron == "* * * * *" - - def test_creates_interval_trigger(self): - """@on_schedule with interval should create ScheduleTrigger.""" - - @on_schedule(interval_seconds=60) - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.trigger.interval_seconds == 60 - - -class TestRequireAdminDecorator: - """Tests for @require_admin decorator.""" - - def test_sets_require_admin_permission(self): - """@require_admin should set require_admin in permissions.""" - - @require_admin - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.permissions.require_admin is True - - -class TestProvideCapabilityDecorator: - """Tests for @provide_capability decorator.""" - - def test_sets_capability_meta(self): - """@provide_capability should attach capability descriptor metadata.""" - - @provide_capability( - "demo.echo", - description="Echo text", - input_schema={"type": "object"}, - output_schema={"type": "object"}, - ) - async def echo(payload): - return payload - - meta = get_capability_meta(echo) - assert meta is not None - assert meta.descriptor.name == "demo.echo" - - def test_rejects_reserved_namespaces(self): - """@provide_capability should reject framework-reserved prefixes.""" - for name in ("handler.echo", "system.echo", "internal.echo"): - with pytest.raises(ValueError, match=name): - - @provide_capability( - name, - description="reserved", - ) - async def reserved(payload): - return payload - - def test_supports_input_output_models(self): - """@provide_capability should accept pydantic models and derive schemas.""" - - class EchoInput(BaseModel): - text: str - - class EchoOutput(BaseModel): - echoed: str - - @provide_capability( - "demo.typed", - description="typed capability", - input_model=EchoInput, - output_model=EchoOutput, - ) - async def typed(payload): - return payload - - meta = get_capability_meta(typed) - assert meta is not None - assert meta.descriptor.input_schema["properties"]["text"]["type"] == "string" - assert meta.descriptor.output_schema["properties"]["echoed"]["type"] == "string" - - def test_rejects_schema_and_model_conflicts(self): - """@provide_capability should reject mixed schema/model declarations.""" - - class EchoInput(BaseModel): - text: str - - with pytest.raises( - ValueError, match="input_schema 和 input_model 不能同时提供" - ): - - @provide_capability( - "demo.conflict", - description="conflict capability", - input_schema={"type": "object"}, - input_model=EchoInput, - ) - async def conflict(payload): - return payload - - def test_rejects_non_pydantic_models(self): - """@provide_capability should require BaseModel subclasses.""" - with pytest.raises( - TypeError, match="input_model 必须是 pydantic BaseModel 子类" - ): - - @provide_capability( - "demo.invalid_model", - description="invalid model", - input_model=dict, - ) - async def invalid(payload): - return payload - - def test_can_combine_with_other_decorators(self): - """@require_admin can be combined with other decorators.""" - - @on_command("admin") - @require_admin - async def handler(): - pass - - meta = get_handler_meta(handler) - assert isinstance(meta.trigger, CommandTrigger) - assert meta.trigger.command == "admin" - assert meta.permissions.require_admin is True - - def test_order_does_not_matter_for_permissions(self): - """Decorator order should not affect permissions.""" - - @require_admin - @on_command("admin") - async def handler1(): - pass - - @on_command("admin") - @require_admin - async def handler2(): - pass - - meta1 = get_handler_meta(handler1) - meta2 = get_handler_meta(handler2) - - assert meta1.permissions.require_admin is True - assert meta2.permissions.require_admin is True - - -class TestDecoratorChaining: - """Tests for chaining multiple decorators.""" - - def test_multiple_decorators_share_meta(self): - """Multiple decorators should share the same meta object.""" - - @on_command("test") - @require_admin - async def handler(): - pass - - meta = get_handler_meta(handler) - assert isinstance(meta.trigger, CommandTrigger) - assert meta.permissions.require_admin is True - - def test_last_trigger_wins(self): - """Last trigger decorator should override previous ones.""" - - # Decorators are applied bottom-up, so on_message wins - @on_message() - @on_command("override") - async def handler(): - pass - - meta = get_handler_meta(handler) - # on_message is applied last (outermost), so it wins - assert isinstance(meta.trigger, MessageTrigger) - - def test_permissions_accumulate(self): - """Permissions should accumulate across decorators.""" - - @require_admin - @on_command("test") - async def handler(): - pass - - meta = get_handler_meta(handler) - assert meta.permissions.require_admin is True diff --git a/tests_v4/test_entrypoints.py b/tests_v4/test_entrypoints.py deleted file mode 100644 index 7d1b2c86e..000000000 --- a/tests_v4/test_entrypoints.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path -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 - - -def _astr_console_script() -> str | None: - executable_dir = Path(sys.executable).resolve().parent - search_dirs = [executable_dir] - if executable_dir.name.lower() != "scripts": - search_dirs.append(executable_dir / "Scripts") - - for scripts_dir in search_dirs: - candidates = [ - scripts_dir / "astr", - scripts_dir / "astr.exe", - scripts_dir / "astr.cmd", - ] - for candidate in candidates: - if candidate.exists(): - return str(candidate) - return None - - -@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( - [sys.executable, "-c", "import astrbot_sdk; print(astrbot_sdk.__name__)"], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(process.returncode, 0, process.stderr) - self.assertIn("astrbot_sdk", process.stdout) - - def test_module_help(self) -> None: - process = subprocess.run( - [sys.executable, "-m", "astrbot_sdk", "--help"], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(process.returncode, 0, process.stderr) - self.assertIn("Usage", process.stdout) - - def test_run_help(self) -> None: - process = subprocess.run( - [sys.executable, "-m", "astrbot_sdk", "run", "--help"], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(process.returncode, 0, process.stderr) - self.assertIn("--plugins-dir", process.stdout) - - def test_console_script_help(self) -> None: - console_script = _astr_console_script() - if console_script is None: - self.fail("astr console script not found") - - process = subprocess.run( - [console_script, "--help"], - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(process.returncode, 0, process.stderr) - self.assertIn("Usage", process.stdout) diff --git a/tests_v4/test_events.py b/tests_v4/test_events.py deleted file mode 100644 index 2561a8e6d..000000000 --- a/tests_v4/test_events.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -Unit tests for Events module. -""" - -from __future__ import annotations - -import pytest - -from astrbot_sdk.events import MessageEvent, PlainTextResult -from astrbot_sdk.testing import MockContext - - -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" - - def test_from_payload_reads_target_shape(self): - """from_payload() should derive session/platform from structured target payload.""" - event = MessageEvent.from_payload( - { - "text": "hello", - "target": { - "conversation_id": "session-1", - "platform": "test-platform", - }, - } - ) - - assert event.session_id == "session-1" - assert event.platform == "test-platform" - - @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") - - @pytest.mark.asyncio - async def test_reply_image_uses_bound_runtime_context(self): - ctx = MockContext(plugin_id="demo") - event = MessageEvent( - text="hello", session_id="s1", platform="test", context=ctx - ) - - await event.reply_image("https://example.com/demo.png") - - assert ctx.sent_messages[0].kind == "image" - assert ctx.sent_messages[0].image_url == "https://example.com/demo.png" - - @pytest.mark.asyncio - async def test_reply_chain_uses_structured_target(self): - ctx = MockContext(plugin_id="demo") - event = MessageEvent.from_payload( - { - "text": "hello", - "target": { - "conversation_id": "session-1", - "platform": "test-platform", - }, - }, - context=ctx, - ) - - await event.reply_chain([{"type": "Plain", "text": "hi"}]) - - assert ctx.sent_messages[0].kind == "chain" - assert ctx.sent_messages[0].session_id == "session-1" - assert ctx.sent_messages[0].target == { - "conversation_id": "session-1", - "platform": "test-platform", - "raw": { - "text": "hello", - "target": { - "conversation_id": "session-1", - "platform": "test-platform", - }, - }, - } - - @pytest.mark.asyncio - async def test_reply_image_without_runtime_context_raises(self): - event = MessageEvent(text="hello", session_id="s1") - - with pytest.raises(RuntimeError, match="未绑定运行时上下文"): - await event.reply_image("https://example.com/demo.png") - - 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" - assert payload["target"]["conversation_id"] == "session-1" - - def test_to_payload_preserves_extra_raw_fields(self): - """to_payload() should preserve unmodeled raw fields during round-trip.""" - event = MessageEvent.from_payload( - { - "text": "hello", - "session_id": "session-1", - "trace_id": "trace-123", - } - ) - event.text = "updated" - - payload = event.to_payload() - - assert payload["trace_id"] == "trace-123" - assert payload["text"] == "updated" - - 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" diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py deleted file mode 100644 index cf1449e33..000000000 --- a/tests_v4/test_handler_dispatcher.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -import pytest - -from astrbot_sdk._invocation_context import current_caller_plugin_id -from astrbot_sdk.context import CancelToken, Context -from astrbot_sdk.events import MessageEvent -from astrbot_sdk.protocol.descriptors import ( - CapabilityDescriptor, - HandlerDescriptor, - MessageTrigger, -) -from astrbot_sdk.runtime.handler_dispatcher import ( - CapabilityDispatcher, - HandlerDispatcher, -) -from astrbot_sdk.runtime.loader import LoadedCapability, LoadedHandler -from astrbot_sdk.testing import MockCapabilityRouter, MockPeer - - -def _peer(): - return MockPeer(MockCapabilityRouter()) - - -class TestHandlerDispatcherArgumentValidation: - def test_handler_dispatcher_raises_for_uninjectable_required_param(self): - peer = _peer() - dispatcher = HandlerDispatcher(plugin_id="demo", peer=peer, handlers=[]) - ctx = Context(peer=peer, plugin_id="demo", cancel_token=CancelToken()) - event = MessageEvent(text="hello", session_id="s1", context=ctx) - - async def bad_handler(event: MessageEvent, missing: str) -> None: - return None - - with pytest.raises(TypeError) as raised: - dispatcher._build_args(bad_handler, event, ctx, args={}) - message = str(raised.value) - assert "插件 'demo' 的 handler" in message - assert "必填参数 'missing' 无法注入" in message - assert "MessageEvent / Context" in message - - def test_capability_dispatcher_raises_for_uninjectable_required_param(self): - peer = _peer() - capability = LoadedCapability( - descriptor=CapabilityDescriptor(name="demo.cap", description="demo"), - callable=lambda ctx, missing: {"ok": True}, - owner=object(), - plugin_id="demo", - ) - dispatcher = CapabilityDispatcher( - plugin_id="demo", - peer=peer, - capabilities=[capability], - ) - ctx = Context(peer=peer, plugin_id="demo", cancel_token=CancelToken()) - - with pytest.raises(TypeError) as raised: - dispatcher._build_args( - capability.callable, - payload={}, - ctx=ctx, - cancel_token=CancelToken(), - ) - message = str(raised.value) - assert "插件 'demo' 的 capability" in message - assert "必填参数 'missing' 无法注入" in message - assert "Context / CancelToken / dict" in message - - -class TestHandlerDispatcherInvoke: - @pytest.mark.asyncio - async def test_invoke_reports_missing_injected_param(self): - peer = _peer() - - async def bad_handler(event: MessageEvent, missing: str) -> None: - return None - - loaded = LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:plugin.bad_handler", - trigger=MessageTrigger(), - ), - callable=bad_handler, - owner=object(), - plugin_id="demo", - ) - dispatcher = HandlerDispatcher(plugin_id="demo", peer=peer, handlers=[loaded]) - - class Message: - id = "req-1" - input = { - "handler_id": "demo:plugin.bad_handler", - "event": {"text": "hello", "session_id": "s1"}, - } - - with pytest.raises(TypeError) as raised: - await dispatcher.invoke(Message(), CancelToken()) - message = str(raised.value) - assert "demo:plugin.bad_handler" in message - assert "必填参数 'missing' 无法注入" in message - - @pytest.mark.asyncio - async def test_invoke_binds_runtime_caller_plugin_id_for_raw_peer_calls(self): - seen: list[str | None] = [] - - class RecordingPeer: - remote_capability_map = {} - remote_peer = object() - - async def invoke(self, capability, payload, *, stream=False): - seen.append(current_caller_plugin_id()) - return {"ok": True} - - peer = RecordingPeer() - - async def handler(ctx: Context) -> None: - await ctx.peer.invoke("metadata.list_plugins", {}, stream=False) - - loaded = LoadedHandler( - descriptor=HandlerDescriptor( - id="demo:plugin.handler", - trigger=MessageTrigger(), - ), - callable=handler, - owner=object(), - plugin_id="demo", - ) - dispatcher = HandlerDispatcher(plugin_id="demo", peer=peer, handlers=[loaded]) - - class Message: - id = "req-2" - input = { - "handler_id": "demo:plugin.handler", - "event": {"text": "hello", "session_id": "s1"}, - } - - await dispatcher.invoke(Message(), CancelToken()) - - assert seen == ["demo"] diff --git a/tests_v4/test_http_metadata_clients.py b/tests_v4/test_http_metadata_clients.py deleted file mode 100644 index 2dc38c893..000000000 --- a/tests_v4/test_http_metadata_clients.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Tests for HTTPClient and MetadataClient.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients.http import HTTPClient -from astrbot_sdk.clients.metadata import MetadataClient, PluginMetadata -from astrbot_sdk.clients._proxy import CapabilityProxy -from astrbot_sdk.decorators import provide_capability -from astrbot_sdk.errors import AstrBotError - - -class TestHTTPClient: - """Tests for HTTPClient.""" - - @pytest.fixture - def mock_proxy(self): - """Create a mock CapabilityProxy.""" - proxy = MagicMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - return proxy - - @pytest.fixture - def http_client(self, mock_proxy): - """Create HTTPClient with mock proxy.""" - return HTTPClient(mock_proxy) - - @pytest.mark.asyncio - async def test_register_api_calls_proxy_with_correct_args( - self, http_client, mock_proxy - ): - """register_api should call proxy with correct arguments.""" - await http_client.register_api( - route="/test-api", - handler_capability="test_plugin.http_handler", - methods=["GET", "POST"], - description="Test API", - ) - - mock_proxy.call.assert_called_once_with( - "http.register_api", - { - "route": "/test-api", - "methods": ["GET", "POST"], - "handler_capability": "test_plugin.http_handler", - "description": "Test API", - }, - ) - - @pytest.mark.asyncio - async def test_register_api_defaults_to_get(self, http_client, mock_proxy): - """register_api should default to GET method.""" - await http_client.register_api( - route="/test-api", - handler_capability="test_plugin.http_handler", - ) - - call_args = mock_proxy.call.call_args - assert call_args[0][1]["methods"] == ["GET"] - - @pytest.mark.asyncio - async def test_register_api_accepts_capability_handler_reference( - self, http_client, mock_proxy - ): - class DemoPlugin: - @provide_capability( - "demo.http_handler", - description="handle http requests", - ) - async def http_handler(self, payload): - return payload - - plugin = DemoPlugin() - await http_client.register_api( - route="/test-api", - handler=plugin.http_handler, - methods=["POST"], - ) - - mock_proxy.call.assert_called_once_with( - "http.register_api", - { - "route": "/test-api", - "methods": ["POST"], - "handler_capability": "demo.http_handler", - "description": "", - }, - ) - - @pytest.mark.asyncio - async def test_register_api_rejects_conflicting_handler_inputs( - self, http_client, mock_proxy - ): - class DemoPlugin: - @provide_capability( - "demo.http_handler", - description="handle http requests", - ) - async def http_handler(self, payload): - return payload - - plugin = DemoPlugin() - with pytest.raises(AstrBotError, match="不能同时提供"): - await http_client.register_api( - route="/test-api", - handler_capability="demo.http_handler", - handler=plugin.http_handler, - ) - mock_proxy.call.assert_not_called() - - @pytest.mark.asyncio - async def test_register_api_rejects_non_capability_handler( - self, http_client, mock_proxy - ): - class DemoPlugin: - async def plain_method(self, payload): - return payload - - plugin = DemoPlugin() - with pytest.raises( - AstrBotError, match="需要传入使用 @provide_capability 声明的方法" - ): - await http_client.register_api( - route="/test-api", - handler=plugin.plain_method, - ) - mock_proxy.call.assert_not_called() - - @pytest.mark.asyncio - async def test_unregister_api_calls_proxy(self, http_client, mock_proxy): - """unregister_api should call proxy with correct arguments.""" - await http_client.unregister_api("/test-api", methods=["GET"]) - - mock_proxy.call.assert_called_once_with( - "http.unregister_api", - {"route": "/test-api", "methods": ["GET"]}, - ) - - @pytest.mark.asyncio - async def test_unregister_api_defaults_to_all_methods( - self, http_client, mock_proxy - ): - """unregister_api should pass empty methods list for all methods.""" - await http_client.unregister_api("/test-api") - - call_args = mock_proxy.call.call_args - assert call_args[0][1]["methods"] == [] - - @pytest.mark.asyncio - async def test_list_apis_returns_apis_from_proxy(self, http_client, mock_proxy): - """list_apis should return apis from proxy response.""" - mock_proxy.call.return_value = { - "apis": [ - {"route": "/api1", "methods": ["GET"], "description": "API 1"}, - {"route": "/api2", "methods": ["POST"], "description": "API 2"}, - ] - } - - result = await http_client.list_apis() - - assert len(result) == 2 - assert result[0]["route"] == "/api1" - assert result[1]["route"] == "/api2" - - @pytest.mark.asyncio - async def test_list_apis_returns_empty_list_when_no_apis( - self, http_client, mock_proxy - ): - """list_apis should return empty list when no apis.""" - mock_proxy.call.return_value = {} - - result = await http_client.list_apis() - - assert result == [] - - -class TestMetadataClient: - """Tests for MetadataClient.""" - - @pytest.fixture - def mock_proxy(self): - """Create a mock CapabilityProxy.""" - proxy = MagicMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - return proxy - - @pytest.fixture - def metadata_client(self, mock_proxy): - """Create MetadataClient with mock proxy.""" - return MetadataClient(mock_proxy, "current_plugin") - - @pytest.mark.asyncio - async def test_get_plugin_returns_metadata(self, metadata_client, mock_proxy): - """get_plugin should return PluginMetadata when plugin exists.""" - mock_proxy.call.return_value = { - "plugin": { - "name": "test_plugin", - "display_name": "Test Plugin", - "desc": "A test plugin", - "author": "test_author", - "version": "1.0.0", - "enabled": True, - } - } - - result = await metadata_client.get_plugin("test_plugin") - - assert result is not None - assert result.name == "test_plugin" - assert result.display_name == "Test Plugin" - assert result.author == "test_author" - assert result.version == "1.0.0" - - @pytest.mark.asyncio - async def test_get_plugin_returns_none_when_not_found( - self, metadata_client, mock_proxy - ): - """get_plugin should return None when plugin not found.""" - mock_proxy.call.return_value = {} - - result = await metadata_client.get_plugin("nonexistent") - - assert result is None - - @pytest.mark.asyncio - async def test_list_plugins_returns_list(self, metadata_client, mock_proxy): - """list_plugins should return list of PluginMetadata.""" - mock_proxy.call.return_value = { - "plugins": [ - { - "name": "plugin1", - "display_name": "Plugin 1", - "author": "a1", - "version": "1.0", - }, - { - "name": "plugin2", - "display_name": "Plugin 2", - "author": "a2", - "version": "2.0", - }, - ] - } - - result = await metadata_client.list_plugins() - - assert len(result) == 2 - assert result[0].name == "plugin1" - assert result[1].name == "plugin2" - - @pytest.mark.asyncio - async def test_list_plugins_returns_empty_list(self, metadata_client, mock_proxy): - """list_plugins should return empty list when no plugins.""" - mock_proxy.call.return_value = {} - - result = await metadata_client.list_plugins() - - assert result == [] - - @pytest.mark.asyncio - async def test_get_plugin_config_returns_config_for_current_plugin( - self, metadata_client, mock_proxy - ): - """get_plugin_config should return config for current plugin.""" - mock_proxy.call.return_value = {"config": {"key": "value"}} - - result = await metadata_client.get_plugin_config() - - mock_proxy.call.assert_called_once_with( - "metadata.get_plugin_config", - {"name": "current_plugin"}, - ) - assert result == {"key": "value"} - - @pytest.mark.asyncio - async def test_get_plugin_config_returns_none_for_other_plugin( - self, metadata_client, mock_proxy - ): - """get_plugin_config should return None when querying other plugin's config.""" - # Mock proxy.call should not be called - result = await metadata_client.get_plugin_config("other_plugin") - - # Should not call proxy for other plugin - mock_proxy.call.assert_not_called() - assert result is None - - @pytest.mark.asyncio - async def test_get_current_plugin_returns_current_plugin_metadata( - self, metadata_client, mock_proxy - ): - """get_current_plugin should return current plugin's metadata.""" - mock_proxy.call.return_value = { - "plugin": { - "name": "current_plugin", - "display_name": "Current Plugin", - "author": "test_author", - "version": "1.0.0", - } - } - - result = await metadata_client.get_current_plugin() - - assert result is not None - assert result.name == "current_plugin" - - @pytest.mark.asyncio - async def test_get_plugin_uses_business_payload_only( - self, metadata_client, mock_proxy - ): - """Metadata request payload should not expose runtime caller identity.""" - mock_proxy.call.return_value = {"plugin": None} - - await metadata_client.get_plugin("other_plugin") - - mock_proxy.call.assert_called_once_with( - "metadata.get_plugin", - {"name": "other_plugin"}, - ) - - @pytest.mark.asyncio - async def test_list_plugins_uses_empty_payload(self, metadata_client, mock_proxy): - """list_plugins should not expose runtime caller identity in payload.""" - mock_proxy.call.return_value = {"plugins": []} - - await metadata_client.list_plugins() - - mock_proxy.call.assert_called_once_with( - "metadata.list_plugins", - {}, - ) - - -class TestPluginMetadata: - """Tests for PluginMetadata dataclass.""" - - def test_from_dict_creates_metadata(self): - """from_dict should create PluginMetadata from dict.""" - data = { - "name": "test_plugin", - "display_name": "Test Plugin", - "desc": "A test plugin", - "author": "test_author", - "version": "1.0.0", - "enabled": True, - } - - result = PluginMetadata.from_dict(data) - - assert result.name == "test_plugin" - assert result.display_name == "Test Plugin" - assert result.description == "A test plugin" - assert result.author == "test_author" - assert result.version == "1.0.0" - assert result.enabled is True - - def test_from_dict_uses_name_as_display_name_fallback(self): - """from_dict should use name as display_name fallback.""" - data = {"name": "test_plugin"} - - result = PluginMetadata.from_dict(data) - - assert result.display_name == "test_plugin" - - def test_from_dict_uses_description_as_desc_fallback(self): - """from_dict should use description field as fallback for desc.""" - data = {"name": "test", "description": "Test description"} - - result = PluginMetadata.from_dict(data) - - assert result.description == "Test description" - - def test_from_dict_defaults_version(self): - """from_dict should default version to 0.0.0.""" - data = {"name": "test_plugin"} - - result = PluginMetadata.from_dict(data) - - assert result.version == "0.0.0" - - def test_from_dict_defaults_enabled(self): - """from_dict should default enabled to True.""" - data = {"name": "test_plugin"} - - result = PluginMetadata.from_dict(data) - - assert result.enabled is True diff --git a/tests_v4/test_llm_client.py b/tests_v4/test_llm_client.py deleted file mode 100644 index 6d6669089..000000000 --- a/tests_v4/test_llm_client.py +++ /dev/null @@ -1,312 +0,0 @@ -""" -Tests for clients/llm.py - LLMClient and related models. -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients.llm import ChatMessage, LLMClient, LLMResponse -from astrbot_sdk.clients._proxy import CapabilityProxy - - -class TestChatMessage: - """Tests for ChatMessage model.""" - - def test_create_with_role_and_content(self): - """ChatMessage should have role and content.""" - msg = ChatMessage(role="user", content="Hello") - assert msg.role == "user" - assert msg.content == "Hello" - - def test_model_dump(self): - """ChatMessage should serialize correctly.""" - msg = ChatMessage(role="assistant", content="Hi there") - data = msg.model_dump() - assert data == {"role": "assistant", "content": "Hi there"} - - -class TestLLMResponse: - """Tests for LLMResponse model.""" - - def test_create_with_text_only(self): - """LLMResponse should work with just text.""" - response = LLMResponse(text="Hello") - assert response.text == "Hello" - assert response.usage is None - assert response.finish_reason is None - assert response.tool_calls == [] - - def test_create_with_all_fields(self): - """LLMResponse should accept all fields.""" - response = LLMResponse( - text="Response", - usage={"prompt_tokens": 10, "completion_tokens": 5}, - finish_reason="stop", - tool_calls=[{"name": "search", "args": {"query": "test"}}], - ) - assert response.text == "Response" - assert response.usage["prompt_tokens"] == 10 - assert response.finish_reason == "stop" - assert len(response.tool_calls) == 1 - - def test_model_validate(self): - """LLMResponse should validate from dict.""" - data = { - "text": "Validated", - "usage": {"total_tokens": 100}, - "finish_reason": "length", - "tool_calls": [], - } - response = LLMResponse.model_validate(data) - assert response.text == "Validated" - assert response.usage["total_tokens"] == 100 - - -class TestLLMClientInit: - """Tests for LLMClient initialization.""" - - def test_init_with_proxy(self): - """LLMClient should store proxy reference.""" - proxy = MagicMock(spec=CapabilityProxy) - client = LLMClient(proxy) - assert client._proxy is proxy - - -class TestLLMClientChat: - """Tests for LLMClient.chat() method.""" - - @pytest.mark.asyncio - async def test_chat_with_prompt_only(self): - """chat() should work with just prompt.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "Hello back"}) - - client = LLMClient(proxy) - result = await client.chat("Hello") - - proxy.call.assert_called_once() - call_args = proxy.call.call_args - assert call_args[0][0] == "llm.chat" - assert call_args[0][1]["prompt"] == "Hello" - assert result == "Hello back" - - @pytest.mark.asyncio - async def test_chat_with_system_prompt(self): - """chat() should pass system prompt.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "Response"}) - - client = LLMClient(proxy) - result = await client.chat("Hi", system="Be helpful") - - call_args = proxy.call.call_args[0][1] - assert call_args["system"] == "Be helpful" - assert result == "Response" - - @pytest.mark.asyncio - async def test_chat_with_history(self): - """chat() should pass conversation history.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "OK"}) - - client = LLMClient(proxy) - history = [ - ChatMessage(role="user", content="Hello"), - ChatMessage(role="assistant", content="Hi"), - ] - await client.chat("How are you?", history=history) - - call_args = proxy.call.call_args[0][1] - assert len(call_args["history"]) == 2 - assert call_args["history"][0] == {"role": "user", "content": "Hello"} - - @pytest.mark.asyncio - async def test_chat_accepts_dict_history_and_extra_kwargs(self): - """chat() should normalize dict history items and pass through extras.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "OK"}) - - client = LLMClient(proxy) - await client.chat( - "How are you?", - history=[{"role": "user", "content": "Hello"}], - image_urls=["https://example.com/a.png"], - tools=[{"name": "search"}], - ) - - call_args = proxy.call.call_args[0][1] - assert call_args["history"] == [{"role": "user", "content": "Hello"}] - assert call_args["image_urls"] == ["https://example.com/a.png"] - assert call_args["tools"] == [{"name": "search"}] - - @pytest.mark.asyncio - async def test_chat_with_model_and_temperature(self): - """chat() should pass model and temperature.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "Done"}) - - client = LLMClient(proxy) - await client.chat("Test", model="gpt-4", temperature=0.5) - - call_args = proxy.call.call_args[0][1] - assert call_args["model"] == "gpt-4" - assert call_args["temperature"] == 0.5 - - @pytest.mark.asyncio - async def test_chat_returns_empty_string_for_missing_text(self): - """chat() should return empty string if text is missing.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = LLMClient(proxy) - result = await client.chat("Hello") - - assert result == "" - - -class TestLLMClientChatRaw: - """Tests for LLMClient.chat_raw() method.""" - - @pytest.mark.asyncio - async def test_chat_raw_returns_llm_response(self): - """chat_raw() should return LLMResponse object.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock( - return_value={ - "text": "Raw response", - "usage": {"tokens": 50}, - "finish_reason": "stop", - "tool_calls": [], - } - ) - - client = LLMClient(proxy) - result = await client.chat_raw("Test") - - assert isinstance(result, LLMResponse) - assert result.text == "Raw response" - assert result.usage["tokens"] == 50 - - @pytest.mark.asyncio - async def test_chat_raw_passes_kwargs(self): - """chat_raw() should pass additional kwargs to proxy.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "OK"}) - - client = LLMClient(proxy) - await client.chat_raw("Test", custom_param="value", another=123) - - call_args = proxy.call.call_args[0][1] - assert call_args["custom_param"] == "value" - assert call_args["another"] == 123 - - @pytest.mark.asyncio - async def test_chat_raw_normalizes_history_items(self): - """chat_raw() should serialize ChatMessage history items before proxy call.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"text": "OK"}) - - client = LLMClient(proxy) - await client.chat_raw( - "Test", - history=[ChatMessage(role="user", content="Hello")], - ) - - call_args = proxy.call.call_args[0][1] - assert call_args["history"] == [{"role": "user", "content": "Hello"}] - - -class TestLLMClientStreamChat: - """Tests for LLMClient.stream_chat() method.""" - - @pytest.mark.asyncio - async def test_stream_chat_yields_text_chunks(self): - """stream_chat() should yield text chunks.""" - proxy = MagicMock(spec=CapabilityProxy) - - async def mock_stream(name, payload): - yield {"text": "Hello"} - yield {"text": " "} - yield {"text": "World"} - - proxy.stream = mock_stream - - client = LLMClient(proxy) - chunks = [] - async for chunk in client.stream_chat("Test"): - chunks.append(chunk) - - assert chunks == ["Hello", " ", "World"] - - @pytest.mark.asyncio - async def test_stream_chat_with_system_and_history(self): - """stream_chat() should pass system and history.""" - proxy = MagicMock(spec=CapabilityProxy) - - captured_payload = None - - async def mock_stream(name, payload): - nonlocal captured_payload - captured_payload = payload - yield {"text": "Done"} - - proxy.stream = mock_stream - - client = LLMClient(proxy) - history = [ChatMessage(role="user", content="Hi")] - chunks = [] - async for chunk in client.stream_chat( - "Test", system="Be nice", history=history - ): - chunks.append(chunk) - - assert captured_payload["system"] == "Be nice" - assert len(captured_payload["history"]) == 1 - - @pytest.mark.asyncio - async def test_stream_chat_passes_extra_kwargs(self): - """stream_chat() should pass through advanced kwargs.""" - proxy = MagicMock(spec=CapabilityProxy) - - captured_payload = None - - async def mock_stream(name, payload): - nonlocal captured_payload - captured_payload = payload - yield {"text": "Done"} - - proxy.stream = mock_stream - - client = LLMClient(proxy) - chunks = [] - async for chunk in client.stream_chat( - "Test", - image_urls=["https://example.com/a.png"], - tools=[{"name": "search"}], - ): - chunks.append(chunk) - - assert chunks == ["Done"] - assert captured_payload["image_urls"] == ["https://example.com/a.png"] - assert captured_payload["tools"] == [{"name": "search"}] - - @pytest.mark.asyncio - async def test_stream_chat_yields_empty_string_for_missing_text(self): - """stream_chat() should yield empty string if text is missing.""" - proxy = MagicMock(spec=CapabilityProxy) - - async def mock_stream(name, payload): - yield {} - yield {"other": "data"} - - proxy.stream = mock_stream - - client = LLMClient(proxy) - chunks = [] - async for chunk in client.stream_chat("Test"): - chunks.append(chunk) - - assert chunks == ["", ""] diff --git a/tests_v4/test_memory_client.py b/tests_v4/test_memory_client.py deleted file mode 100644 index c8aa54f17..000000000 --- a/tests_v4/test_memory_client.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Tests for clients/memory.py - MemoryClient implementation. -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients.memory import MemoryClient -from astrbot_sdk.clients._proxy import CapabilityProxy - - -class TestMemoryClientInit: - """Tests for MemoryClient initialization.""" - - def test_init_with_proxy(self): - """MemoryClient should store proxy reference.""" - proxy = MagicMock(spec=CapabilityProxy) - client = MemoryClient(proxy) - assert client._proxy is proxy - - -class TestMemoryClientSearch: - """Tests for MemoryClient.search() method.""" - - @pytest.mark.asyncio - async def test_search_returns_items(self): - """search() should return list of items.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock( - return_value={ - "items": [ - {"id": "1", "content": "first"}, - {"id": "2", "content": "second"}, - ] - } - ) - - client = MemoryClient(proxy) - result = await client.search("test query") - - proxy.call.assert_called_once_with( - "memory.search", - {"query": "test query"}, - ) - assert len(result) == 2 - assert result[0]["content"] == "first" - - @pytest.mark.asyncio - async def test_search_returns_empty_list_for_no_results(self): - """search() should return empty list when no items found.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - result = await client.search("nonexistent") - - assert result == [] - - @pytest.mark.asyncio - async def test_search_with_empty_query(self): - """search() should work with empty query.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"items": []}) - - client = MemoryClient(proxy) - result = await client.search("") - - proxy.call.assert_called_once_with("memory.search", {"query": ""}) - assert result == [] - - @pytest.mark.asyncio - async def test_search_returns_empty_list_for_malformed_items(self): - """search() should ignore malformed non-list item payloads.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"items": "bad"}) - - client = MemoryClient(proxy) - result = await client.search("test") - - assert result == [] - - -class TestMemoryClientSave: - """Tests for MemoryClient.save() method.""" - - @pytest.mark.asyncio - async def test_save_with_key_and_value(self): - """save() should call proxy with key and value.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.save("my_key", {"data": "value"}) - - proxy.call.assert_called_once_with( - "memory.save", - {"key": "my_key", "value": {"data": "value"}}, - ) - - @pytest.mark.asyncio - async def test_save_with_extra_kwargs(self): - """save() should merge extra kwargs into value.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.save("key", {"base": 1}, extra="added", another=2) - - call_args = proxy.call.call_args[0][1] - assert call_args["value"] == {"base": 1, "extra": "added", "another": 2} - - @pytest.mark.asyncio - async def test_save_with_only_kwargs(self): - """save() should work with only kwargs (no value).""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.save("key", name="test", count=5) - - call_args = proxy.call.call_args[0][1] - assert call_args["value"] == {"name": "test", "count": 5} - - @pytest.mark.asyncio - async def test_save_with_none_value(self): - """save() should handle None value with kwargs.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.save("key", None, field="value") - - call_args = proxy.call.call_args[0][1] - assert call_args["value"] == {"field": "value"} - - @pytest.mark.asyncio - async def test_save_with_empty_value_and_no_kwargs(self): - """save() should work with empty dict.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.save("key", {}) - - proxy.call.assert_called_once_with( - "memory.save", - {"key": "key", "value": {}}, - ) - - @pytest.mark.asyncio - async def test_save_raises_type_error_for_non_dict_value(self): - """save() should raise TypeError for non-dict value.""" - proxy = AsyncMock(spec=CapabilityProxy) - - client = MemoryClient(proxy) - - with pytest.raises(TypeError, match="memory.save 的 value 必须是 dict"): - await client.save("key", "not a dict") - - @pytest.mark.asyncio - async def test_save_raises_type_error_for_list_value(self): - """save() should raise TypeError for list value.""" - proxy = AsyncMock(spec=CapabilityProxy) - - client = MemoryClient(proxy) - - with pytest.raises(TypeError, match="memory.save 的 value 必须是 dict"): - await client.save("key", [1, 2, 3]) - - -class TestMemoryClientGet: - """Tests for MemoryClient.get() method.""" - - @pytest.mark.asyncio - async def test_get_returns_dict_value(self): - """get() should return dict value from proxy response.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": {"theme": "dark"}}) - - client = MemoryClient(proxy) - result = await client.get("user_pref") - - proxy.call.assert_called_once_with("memory.get", {"key": "user_pref"}) - assert result == {"theme": "dark"} - - @pytest.mark.asyncio - async def test_get_returns_none_for_missing_value(self): - """get() should return None when memory is absent.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": None}) - - client = MemoryClient(proxy) - result = await client.get("missing") - - assert result is None - - @pytest.mark.asyncio - async def test_get_returns_none_for_non_dict_value(self): - """get() should ignore malformed non-dict payloads.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"value": "bad"}) - - client = MemoryClient(proxy) - result = await client.get("bad") - - assert result is None - - -class TestMemoryClientDelete: - """Tests for MemoryClient.delete() method.""" - - @pytest.mark.asyncio - async def test_delete_calls_proxy_with_key(self): - """delete() should call proxy with correct key.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.delete("to_delete") - - proxy.call.assert_called_once_with("memory.delete", {"key": "to_delete"}) - - @pytest.mark.asyncio - async def test_delete_with_empty_key(self): - """delete() should work with empty string key.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.delete("") - - proxy.call.assert_called_once_with("memory.delete", {"key": ""}) - - -class TestMemoryClientSaveWithTTL: - """Tests for MemoryClient.save_with_ttl() method.""" - - @pytest.mark.asyncio - async def test_save_with_ttl_calls_proxy(self): - """save_with_ttl() should call proxy with key, value, and ttl_seconds.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - await client.save_with_ttl("temp_key", {"data": "value"}, ttl_seconds=3600) - - proxy.call.assert_called_once_with( - "memory.save_with_ttl", - {"key": "temp_key", "value": {"data": "value"}, "ttl_seconds": 3600}, - ) - - @pytest.mark.asyncio - async def test_save_with_ttl_raises_type_error_for_non_dict(self): - """save_with_ttl() should raise TypeError for non-dict value.""" - proxy = AsyncMock(spec=CapabilityProxy) - client = MemoryClient(proxy) - - with pytest.raises( - TypeError, match="memory.save_with_ttl 的 value 必须是 dict" - ): - await client.save_with_ttl("key", "not a dict", ttl_seconds=60) - - @pytest.mark.asyncio - async def test_save_with_ttl_raises_value_error_for_invalid_ttl(self): - """save_with_ttl() should raise ValueError for ttl_seconds < 1.""" - proxy = AsyncMock(spec=CapabilityProxy) - client = MemoryClient(proxy) - - with pytest.raises(ValueError, match="ttl_seconds 必须大于 0"): - await client.save_with_ttl("key", {"data": 1}, ttl_seconds=0) - - with pytest.raises(ValueError, match="ttl_seconds 必须大于 0"): - await client.save_with_ttl("key", {"data": 1}, ttl_seconds=-1) - - -class TestMemoryClientGetMany: - """Tests for MemoryClient.get_many() method.""" - - @pytest.mark.asyncio - async def test_get_many_returns_items(self): - """get_many() should return list of items with key and value.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock( - return_value={ - "items": [ - {"key": "k1", "value": {"a": 1}}, - {"key": "k2", "value": {"b": 2}}, - ] - } - ) - - client = MemoryClient(proxy) - result = await client.get_many(["k1", "k2"]) - - proxy.call.assert_called_once_with("memory.get_many", {"keys": ["k1", "k2"]}) - assert len(result) == 2 - assert result[0]["key"] == "k1" - assert result[0]["value"] == {"a": 1} - - @pytest.mark.asyncio - async def test_get_many_returns_empty_list_for_malformed_response(self): - """get_many() should return empty list for malformed response.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"items": "not a list"}) - - client = MemoryClient(proxy) - result = await client.get_many(["k1", "k2"]) - - assert result == [] - - @pytest.mark.asyncio - async def test_get_many_returns_empty_list_for_missing_items(self): - """get_many() should return empty list when items key missing.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - result = await client.get_many(["k1"]) - - assert result == [] - - -class TestMemoryClientDeleteMany: - """Tests for MemoryClient.delete_many() method.""" - - @pytest.mark.asyncio - async def test_delete_many_returns_count(self): - """delete_many() should return number of deleted items.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"deleted_count": 3}) - - client = MemoryClient(proxy) - result = await client.delete_many(["k1", "k2", "k3"]) - - proxy.call.assert_called_once_with( - "memory.delete_many", {"keys": ["k1", "k2", "k3"]} - ) - assert result == 3 - - @pytest.mark.asyncio - async def test_delete_many_returns_zero_for_missing_count(self): - """delete_many() should return 0 when deleted_count missing.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - result = await client.delete_many(["k1"]) - - assert result == 0 - - -class TestMemoryClientStats: - """Tests for MemoryClient.stats() method.""" - - @pytest.mark.asyncio - async def test_stats_returns_total_items(self): - """stats() should return total_items count.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"total_items": 42, "total_bytes": 1024}) - - client = MemoryClient(proxy) - result = await client.stats() - - proxy.call.assert_called_once_with("memory.stats", {}) - assert result["total_items"] == 42 - assert result["total_bytes"] == 1024 - - @pytest.mark.asyncio - async def test_stats_defaults_to_zero(self): - """stats() should default total_items to 0 if missing.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = MemoryClient(proxy) - result = await client.stats() - - assert result["total_items"] == 0 - assert result["total_bytes"] is None diff --git a/tests_v4/test_peer.py b/tests_v4/test_peer.py deleted file mode 100644 index 7a8aab17b..000000000 --- a/tests_v4/test_peer.py +++ /dev/null @@ -1,666 +0,0 @@ -from __future__ import annotations - -import asyncio -import unittest - -from astrbot_sdk._invocation_context import caller_plugin_scope -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.protocol.wire_codecs import MsgpackProtocolCodec -from astrbot_sdk.runtime.capability_router import CapabilityRouter, StreamExecution -from astrbot_sdk.runtime.peer import Peer -from astrbot_sdk.runtime.transport import ( - Transport, - WebSocketClientTransport, - WebSocketServerTransport, -) - -from tests_v4.helpers import MemoryTransport, make_transport_pair - - -class LinkedMemoryTransport(MemoryTransport): - async def stop(self) -> None: - if self._closed.is_set(): - return - self._closed.set() - if self.partner is not None and not self.partner._closed.is_set(): - self.partner._closed.set() - - -def make_linked_transport_pair() -> tuple[LinkedMemoryTransport, LinkedMemoryTransport]: - left = LinkedMemoryTransport() - right = LinkedMemoryTransport() - left.partner = right - right.partner = left - return left, right - - -class RecordingTextTransport(Transport): - def __init__(self) -> None: - super().__init__() - self.sent_payloads: list[bytes | str] = [] - - async def start(self) -> None: - self._closed.clear() - - async def stop(self) -> None: - self._closed.set() - - async def send(self, payload): - self.sent_payloads.append(payload) - - -class PeerRuntimeTest(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.left, self.right = make_transport_pair() - - async def test_initialize_and_call_builtin_capabilities(self) -> None: - router = CapabilityRouter() - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - result = await plugin.invoke("llm.chat", {"prompt": "hello"}) - self.assertEqual(result["text"], "Echo: hello") - - stream = await plugin.invoke_stream("llm.stream_chat", {"prompt": "hi"}) - chunks = [event.data["text"] async for event in stream] - self.assertEqual("".join(chunks), "Echo: hi") - - await plugin.stop() - await core.stop() - - async def test_default_json_codec_preserves_text_transport_payloads(self) -> None: - transport = RecordingTextTransport() - peer = Peer( - transport=transport, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await peer.start() - await peer.cancel("request-1") - await peer.stop() - - self.assertEqual(len(transport.sent_payloads), 1) - self.assertIsInstance(transport.sent_payloads[0], str) - - async def test_initialize_carries_remote_provided_capabilities(self) -> None: - provided = CapabilityDescriptor( - name="demo.echo", - description="Echo text", - input_schema={"type": "object", "properties": {"text": {"type": "string"}}}, - output_schema={ - "type": "object", - "properties": {"echo": {"type": "string"}}, - }, - ) - - async def init_handler(message): - self.assertEqual( - [item.name for item in message.provided_capabilities], ["demo.echo"] - ) - return InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ) - - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler(init_handler) - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([], provided_capabilities=[provided]) - - self.assertEqual( - [item.name for item in core.remote_provided_capabilities], - ["demo.echo"], - ) - await plugin.stop() - await core.stop() - - async def test_msgpack_codec_roundtrip(self) -> None: - codec = MsgpackProtocolCodec() - router = CapabilityRouter() - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - codec=codec, - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - codec=codec, - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - result = await plugin.invoke("llm.chat", {"prompt": "hello-msgpack"}) - self.assertEqual(result["text"], "Echo: hello-msgpack") - - await plugin.stop() - await core.stop() - - async def test_wait_until_remote_initialized_after_initialize_returns(self) -> None: - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - await plugin.wait_until_remote_initialized(timeout=0.1) - - await plugin.stop() - await core.stop() - - async def test_invoke_transports_runtime_caller_plugin_id(self) -> None: - captured: list[str | None] = [] - - async def invoke_handler(message, _token): - captured.append(message.caller_plugin_id) - return {"ok": True} - - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - core.set_invoke_handler(invoke_handler) - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - with caller_plugin_scope("demo_plugin"): - result = await plugin.invoke("llm.chat", {"prompt": "hello"}) - - self.assertEqual(result, {"ok": True}) - self.assertEqual(captured, ["demo_plugin"]) - - await plugin.stop() - await core.stop() - - async def test_stream_false_receiving_event_is_protocol_error(self) -> None: - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - await self.left.start() - await plugin.start() - - task = asyncio.create_task( - plugin.invoke("llm.chat", {"prompt": "bad"}, request_id="req-1") - ) - await asyncio.sleep(0) - await self.left.send( - EventMessage(id="req-1", phase="started").model_dump_json() - ) - - with self.assertRaises(AstrBotError) as raised: - await task - self.assertEqual(raised.exception.code, "protocol_error") - await plugin.stop() - await self.left.stop() - - async def test_stream_true_receiving_result_is_protocol_error(self) -> None: - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - await self.left.start() - await plugin.start() - - stream = await plugin.invoke_stream( - "llm.stream_chat", {"prompt": "bad"}, request_id="stream-1" - ) - await self.left.send( - ResultMessage(id="stream-1", success=True, output={}).model_dump_json() - ) - - with self.assertRaises(AstrBotError) as raised: - async for _ in stream: - pass - self.assertEqual(raised.exception.code, "protocol_error") - await plugin.stop() - await self.left.stop() - - async def test_invalid_inbound_message_fails_pending_calls(self) -> None: - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - await self.left.start() - await plugin.start() - - task = asyncio.create_task( - plugin.invoke("llm.chat", {"prompt": "bad"}, request_id="req-invalid") - ) - await asyncio.sleep(0) - - with self.assertRaises(AstrBotError) as raised_send: - await self.left.send("[]") - self.assertEqual(raised_send.exception.code, "protocol_error") - - with self.assertRaises(AstrBotError) as raised_task: - await task - self.assertEqual(raised_task.exception.code, "protocol_error") - - await asyncio.wait_for(plugin.wait_closed(), timeout=1.0) - await self.left.stop() - - async def test_cancel_waits_for_failed_terminal_event(self) -> None: - descriptor = CapabilityDescriptor( - name="slow.stream", - description="slow stream", - input_schema={"type": "object", "properties": {}, "required": []}, - output_schema={ - "type": "object", - "properties": {"count": {"type": "number"}}, - "required": ["count"], - }, - supports_stream=True, - cancelable=True, - ) - - async def init_handler(_message): - return InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[descriptor], - metadata={}, - ) - - async def invoke_handler(message, token: CancelToken): - async def iterator(): - while True: - token.raise_if_cancelled() - await asyncio.sleep(0.01) - yield {"text": "x"} - - return StreamExecution( - iterator=iterator(), - finalize=lambda chunks: {"count": len(chunks)}, - ) - - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler(init_handler) - core.set_invoke_handler(invoke_handler) - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - stream = await plugin.invoke_stream("slow.stream", {}, request_id="cancel-1") - first = await anext(stream) - self.assertEqual(first.data["text"], "x") - await plugin.cancel("cancel-1") - - with self.assertRaises(AstrBotError) as raised: - await anext(stream) - self.assertEqual(raised.exception.code, "cancelled") - await plugin.stop() - await core.stop() - - async def test_websocket_transport_smoke(self) -> None: - router = CapabilityRouter() - server_transport = WebSocketServerTransport(port=0) - core = Peer( - transport=server_transport, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - await core.start() - client_transport = WebSocketClientTransport(url=server_transport.url) - plugin = Peer( - transport=client_transport, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - await plugin.start() - await plugin.initialize([]) - result = await plugin.invoke("llm.chat", {"prompt": "ws"}) - self.assertEqual(result["text"], "Echo: ws") - await plugin.stop() - await core.stop() - - async def test_websocket_transport_smoke_with_msgpack_codec(self) -> None: - codec = MsgpackProtocolCodec() - router = CapabilityRouter() - server_transport = WebSocketServerTransport(port=0) - core = Peer( - transport=server_transport, - peer_info=PeerInfo(name="core", role="core", version="v4"), - codec=codec, - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=router.descriptors(), - metadata={}, - ), - ) - ) - core.set_invoke_handler( - lambda message, token: router.execute( - message.capability, - message.input, - stream=message.stream, - cancel_token=token, - request_id=message.id, - ) - ) - - await core.start() - client_transport = WebSocketClientTransport(url=server_transport.url) - plugin = Peer( - transport=client_transport, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - codec=codec, - ) - await plugin.start() - await plugin.initialize([]) - result = await plugin.invoke("llm.chat", {"prompt": "ws-msgpack"}) - self.assertEqual(result["text"], "Echo: ws-msgpack") - await plugin.stop() - await core.stop() - - async def test_initialize_failure_closes_receiver_connection(self) -> None: - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - protocol_version="1.0", - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - protocol_version="2.0", - supported_protocol_versions=["1.0", "2.0"], - ) - - await core.start() - await plugin.start() - - with self.assertRaises(AstrBotError) as raised: - await plugin.initialize([]) - self.assertEqual(raised.exception.code, "protocol_version_mismatch") - - await asyncio.wait_for(core.wait_closed(), timeout=1.0) - await asyncio.wait_for(plugin.wait_closed(), timeout=1.0) - self.assertTrue(core._closed) - self.assertTrue(plugin._closed) - - async def test_initialize_negotiates_lower_minor_protocol_version(self) -> None: - core = Peer( - transport=self.left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - protocol_version="1.0", - supported_protocol_versions=["1.0"], - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - protocol_version="1.1", - supported_protocol_versions=["1.0", "1.1"], - ) - - await core.start() - await plugin.start() - - output = await plugin.initialize([]) - - self.assertEqual(output.protocol_version, "1.0") - self.assertEqual(plugin.negotiated_protocol_version, "1.0") - self.assertEqual(core.negotiated_protocol_version, "1.0") - self.assertEqual( - core.remote_metadata["supported_protocol_versions"], ["1.1", "1.0"] - ) - self.assertEqual(plugin.remote_metadata["negotiated_protocol_version"], "1.0") - - await plugin.stop() - await core.stop() - - async def test_wait_until_remote_initialized_raises_if_connection_closes_first( - self, - ) -> None: - plugin = Peer( - transport=self.right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await self.left.start() - await plugin.start() - await plugin.stop() - - with self.assertRaises(AstrBotError) as raised: - await plugin.wait_until_remote_initialized(timeout=None) - self.assertEqual(raised.exception.code, "protocol_error") - - await self.left.stop() - - async def test_unexpected_transport_close_fails_pending_invoke(self) -> None: - left, right = make_linked_transport_pair() - started = asyncio.Event() - - async def hanging_invoke(_message, _token): - started.set() - await asyncio.Future() - - core = Peer( - transport=left, - peer_info=PeerInfo(name="core", role="core", version="v4"), - ) - core.set_initialize_handler( - lambda _message: asyncio.sleep( - 0, - result=InitializeOutput( - peer=PeerInfo(name="core", role="core", version="v4"), - capabilities=[], - metadata={}, - ), - ) - ) - core.set_invoke_handler(hanging_invoke) - plugin = Peer( - transport=right, - peer_info=PeerInfo(name="plugin", role="plugin", version="v4"), - ) - - await core.start() - await plugin.start() - await plugin.initialize([]) - - task = asyncio.create_task( - plugin.invoke("llm.chat", {"prompt": "close-me"}, request_id="req-close") - ) - await started.wait() - await left.stop() - - with self.assertRaises(AstrBotError) as raised: - await task - self.assertEqual(raised.exception.code, "network_error") - self.assertTrue(raised.exception.retryable) - - await asyncio.wait_for(plugin.wait_closed(), timeout=1.0) - await asyncio.wait_for(core.wait_closed(), timeout=1.0) - - -class CapabilityRouterContractTest(unittest.TestCase): - def test_capability_names_must_match_namespace_method_format(self) -> None: - router = CapabilityRouter() - for name in ("llm", "llm.chat.extra", "LLM.chat", "llm.Chat"): - with self.assertRaises(ValueError) as raised: - router.register( - CapabilityDescriptor( - name=name, - description="invalid", - ) - ) - self.assertIn(name, str(raised.exception)) - - def test_reserved_capability_namespaces_are_rejected_for_exposed_registrations( - self, - ) -> None: - router = CapabilityRouter() - for name in ("handler.demo", "system.health", "internal.trace"): - with self.assertRaises(ValueError) as raised: - router.register( - CapabilityDescriptor( - name=name, - description="reserved", - ) - ) - self.assertIn(name, str(raised.exception)) - - def test_reserved_capability_namespaces_remain_available_for_hidden_internal_registrations( - self, - ) -> None: - router = CapabilityRouter() - router.register( - CapabilityDescriptor( - name="system.health", - description="internal only", - ), - exposed=False, - ) - - self.assertNotIn("system.health", [item.name for item in router.descriptors()]) diff --git a/tests_v4/test_platform_client.py b/tests_v4/test_platform_client.py deleted file mode 100644 index 4a9745117..000000000 --- a/tests_v4/test_platform_client.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Tests for clients/platform.py - PlatformClient implementation. -""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot_sdk.clients.platform import PlatformClient -from astrbot_sdk.clients._proxy import CapabilityProxy -from astrbot_sdk.protocol.descriptors import SessionRef - - -class TestPlatformClientInit: - """Tests for PlatformClient initialization.""" - - def test_init_with_proxy(self): - """PlatformClient should store proxy reference.""" - proxy = MagicMock(spec=CapabilityProxy) - client = PlatformClient(proxy) - assert client._proxy is proxy - - -class TestPlatformClientSend: - """Tests for PlatformClient.send() method.""" - - @pytest.mark.asyncio - async def test_send_returns_response(self): - """send() should return response dict.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"message_id": "msg_123", "sent": True}) - - client = PlatformClient(proxy) - result = await client.send("session-1", "Hello") - - proxy.call.assert_called_once_with( - "platform.send", - {"session": "session-1", "text": "Hello"}, - ) - assert result["message_id"] == "msg_123" - - @pytest.mark.asyncio - async def test_send_with_empty_text(self): - """send() should work with empty text.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - await client.send("session-1", "") - - call_args = proxy.call.call_args[0][1] - assert call_args["text"] == "" - - @pytest.mark.asyncio - async def test_send_with_special_characters(self): - """send() should handle special characters in text.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - await client.send("session-1", "Hello\nWorld\t! @#$%") - - call_args = proxy.call.call_args[0][1] - assert call_args["text"] == "Hello\nWorld\t! @#$%" - - @pytest.mark.asyncio - async def test_send_with_session_ref_adds_target_payload(self): - """send() should preserve structured session targets while keeping session string.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - await client.send( - SessionRef( - conversation_id="session-1", - platform="test", - raw={"trace_id": "trace-1"}, - ), - "Hello", - ) - - proxy.call.assert_called_once_with( - "platform.send", - { - "session": "session-1", - "target": { - "conversation_id": "session-1", - "platform": "test", - "raw": {"trace_id": "trace-1"}, - }, - "text": "Hello", - }, - ) - - -class TestPlatformClientSendImage: - """Tests for PlatformClient.send_image() method.""" - - @pytest.mark.asyncio - async def test_send_image_returns_response(self): - """send_image() should return response dict.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"image_id": "img_456"}) - - client = PlatformClient(proxy) - result = await client.send_image("session-1", "https://example.com/image.png") - - proxy.call.assert_called_once_with( - "platform.send_image", - {"session": "session-1", "image_url": "https://example.com/image.png"}, - ) - assert result["image_id"] == "img_456" - - @pytest.mark.asyncio - async def test_send_image_with_file_url(self): - """send_image() should work with file:// URL.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - await client.send_image("session-1", "file:///path/to/image.jpg") - - call_args = proxy.call.call_args[0][1] - assert call_args["image_url"] == "file:///path/to/image.jpg" - - @pytest.mark.asyncio - async def test_send_image_with_base64_url(self): - """send_image() should work with data URL.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - await client.send_image("session-1", "data:image/png;base64,abc123") - - call_args = proxy.call.call_args[0][1] - assert call_args["image_url"] == "data:image/png;base64,abc123" - - -class TestPlatformClientSendChain: - """Tests for PlatformClient.send_chain() method.""" - - @pytest.mark.asyncio - async def test_send_chain_returns_response(self): - """send_chain() should return response dict.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"message_id": "chain_123"}) - - client = PlatformClient(proxy) - result = await client.send_chain( - "session-1", - [{"type": "Plain", "text": "Hello"}], - ) - - proxy.call.assert_called_once_with( - "platform.send_chain", - {"session": "session-1", "chain": [{"type": "Plain", "text": "Hello"}]}, - ) - assert result["message_id"] == "chain_123" - - @pytest.mark.asyncio - async def test_send_chain_with_multiple_components(self): - """send_chain() should preserve the original component payloads.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - await client.send_chain( - "session-1", - [ - {"type": "Plain", "text": "Hello"}, - {"type": "Image", "file": "https://example.com/a.png"}, - ], - ) - - call_args = proxy.call.call_args[0][1] - assert call_args["chain"][0]["text"] == "Hello" - assert call_args["chain"][1]["file"] == "https://example.com/a.png" - - -class TestPlatformClientGetMembers: - """Tests for PlatformClient.get_members() method.""" - - @pytest.mark.asyncio - async def test_get_members_returns_list(self): - """get_members() should return list of members.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock( - return_value={ - "members": [ - {"id": "user1", "name": "Alice"}, - {"id": "user2", "name": "Bob"}, - ] - } - ) - - client = PlatformClient(proxy) - result = await client.get_members("group-1") - - proxy.call.assert_called_once_with( - "platform.get_members", - {"session": "group-1"}, - ) - assert len(result) == 2 - assert result[0]["name"] == "Alice" - - @pytest.mark.asyncio - async def test_get_members_returns_empty_list(self): - """get_members() should return empty list when no members.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={}) - - client = PlatformClient(proxy) - result = await client.get_members("empty-group") - - assert result == [] - - @pytest.mark.asyncio - async def test_get_members_returns_empty_list_for_malformed_payload(self): - """get_members() should ignore malformed non-list payloads.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"members": "bad"}) - - client = PlatformClient(proxy) - result = await client.get_members("group-1") - - assert result == [] - - @pytest.mark.asyncio - async def test_get_members_with_private_session(self): - """get_members() should work with private session.""" - proxy = AsyncMock(spec=CapabilityProxy) - proxy.call = AsyncMock(return_value={"members": [{"id": "single_user"}]}) - - client = PlatformClient(proxy) - await client.get_members("private-123") - - call_args = proxy.call.call_args[0][1] - assert call_args["session"] == "private-123" diff --git a/tests_v4/test_protocol.py b/tests_v4/test_protocol.py deleted file mode 100644 index 8d828df71..000000000 --- a/tests_v4/test_protocol.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import unittest - -from astrbot_sdk.protocol.descriptors import ( - CommandTrigger, - HandlerDescriptor, - ScheduleTrigger, -) -from astrbot_sdk.protocol.messages import ( - CancelMessage, - EventMessage, - InitializeMessage, - InvokeMessage, - PeerInfo, - ResultMessage, - parse_message, -) - - -class ProtocolModelsTest(unittest.TestCase): - def test_parse_message_roundtrip(self) -> None: - message = InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer=PeerInfo(name="plugin", role="plugin", version="1.0.0"), - handlers=[ - HandlerDescriptor( - id="handler_1", - trigger=CommandTrigger(command="hello"), - ) - ], - metadata={"a": 1}, - ) - parsed = parse_message(message.model_dump_json()) - self.assertEqual(parsed.id, "msg_001") - self.assertEqual(parsed.peer.name, "plugin") - - for sample in [ - InvokeMessage(id="msg_002", capability="llm.chat", input={"prompt": "hi"}), - ResultMessage(id="msg_002", success=True, output={"text": "ok"}), - EventMessage(id="msg_003", phase="started"), - CancelMessage(id="msg_003"), - ]: - self.assertEqual(parse_message(sample.model_dump()).type, sample.type) - - def test_schedule_trigger_requires_exactly_one_strategy(self) -> None: - with self.assertRaises(ValueError): - ScheduleTrigger() - with self.assertRaises(ValueError): - ScheduleTrigger(cron="* * * * *", interval_seconds=10) - trigger = ScheduleTrigger(interval_seconds=30) - self.assertEqual(trigger.interval_seconds, 30) diff --git a/tests_v4/test_protocol_descriptors.py b/tests_v4/test_protocol_descriptors.py deleted file mode 100644 index 92341a800..000000000 --- a/tests_v4/test_protocol_descriptors.py +++ /dev/null @@ -1,418 +0,0 @@ -""" -Tests for protocol/descriptors.py - Descriptor models. -""" - -from __future__ import annotations - -import pytest -from pydantic import ValidationError - -from astrbot_sdk.protocol.descriptors import ( - BUILTIN_CAPABILITY_SCHEMAS, - CapabilityDescriptor, - CommandTrigger, - DB_LIST_INPUT_SCHEMA, - EventTrigger, - HandlerDescriptor, - LLM_CHAT_INPUT_SCHEMA, - MessageTrigger, - Permissions, - RESERVED_CAPABILITY_PREFIXES, - SESSION_REF_SCHEMA, - ScheduleTrigger, - SessionRef, -) - - -class TestPermissions: - """Tests for Permissions model.""" - - def test_default_values(self): - """Permissions should have default values.""" - perms = Permissions() - assert perms.require_admin is False - assert perms.level == 0 - - def test_custom_values(self): - """Permissions should accept custom values.""" - perms = Permissions(require_admin=True, level=5) - assert perms.require_admin is True - assert perms.level == 5 - - def test_model_dump(self): - """Permissions should serialize correctly.""" - perms = Permissions(require_admin=True, level=10) - data = perms.model_dump() - assert data == {"require_admin": True, "level": 10} - - def test_extra_fields_forbidden(self): - """Permissions should forbid extra fields.""" - with pytest.raises(ValidationError): - Permissions(require_admin=True, unknown_field="value") - - -class TestCommandTrigger: - """Tests for CommandTrigger model.""" - - def test_required_command(self): - """CommandTrigger requires command field.""" - trigger = CommandTrigger(command="hello") - assert trigger.type == "command" - assert trigger.command == "hello" - assert trigger.aliases == [] - assert trigger.description is None - assert trigger.platforms == [] - assert trigger.message_types == [] - - def test_with_aliases_and_description(self): - """CommandTrigger should accept aliases and description.""" - trigger = CommandTrigger( - command="hello", - aliases=["hi", "hey"], - description="Say hello", - ) - assert trigger.command == "hello" - assert trigger.aliases == ["hi", "hey"] - assert trigger.description == "Say hello" - - def test_type_literal(self): - """CommandTrigger type should always be 'command'.""" - trigger = CommandTrigger(command="test") - assert trigger.type == "command" - - def test_extra_fields_forbidden(self): - """CommandTrigger should forbid extra fields.""" - with pytest.raises(ValidationError): - CommandTrigger(command="test", extra="field") - - -class TestMessageTrigger: - """Tests for MessageTrigger model.""" - - def test_default_values(self): - """MessageTrigger should have default values.""" - trigger = MessageTrigger() - assert trigger.type == "message" - assert trigger.regex is None - assert trigger.keywords == [] - assert trigger.platforms == [] - assert trigger.message_types == [] - - def test_with_regex(self): - """MessageTrigger should accept regex pattern.""" - trigger = MessageTrigger(regex=r"^hello.*$") - assert trigger.regex == r"^hello.*$" - - def test_with_keywords(self): - """MessageTrigger should accept keywords.""" - trigger = MessageTrigger(keywords=["hello", "hi"]) - assert trigger.keywords == ["hello", "hi"] - - def test_with_platforms(self): - """MessageTrigger should accept platforms.""" - trigger = MessageTrigger(platforms=["wechat", "qq"]) - assert trigger.platforms == ["wechat", "qq"] - - def test_with_all_fields(self): - """MessageTrigger should accept all fields.""" - trigger = MessageTrigger( - regex=r"test", - keywords=["keyword"], - platforms=["platform"], - message_types=["private"], - ) - assert trigger.regex == "test" - assert trigger.keywords == ["keyword"] - assert trigger.platforms == ["platform"] - assert trigger.message_types == ["private"] - - -class TestEventTrigger: - """Tests for EventTrigger model.""" - - def test_required_event_type(self): - """EventTrigger requires event_type field.""" - trigger = EventTrigger(event_type="message") - assert trigger.type == "event" - assert trigger.event_type == "message" - - def test_numeric_event_type(self): - """EventTrigger should accept numeric string event_type.""" - trigger = EventTrigger(event_type="3") - assert trigger.event_type == "3" - - def test_type_literal(self): - """EventTrigger type should always be 'event'.""" - trigger = EventTrigger(event_type="custom") - assert trigger.type == "event" - - -class TestScheduleTrigger: - """Tests for ScheduleTrigger model.""" - - def test_with_cron(self): - """ScheduleTrigger should accept cron expression.""" - trigger = ScheduleTrigger(cron="0 9 * * *") - assert trigger.type == "schedule" - assert trigger.cron == "0 9 * * *" - assert trigger.interval_seconds is None - - def test_with_interval_seconds(self): - """ScheduleTrigger should accept interval_seconds.""" - trigger = ScheduleTrigger(interval_seconds=60) - assert trigger.interval_seconds == 60 - assert trigger.cron is None - - def test_accepts_schedule_alias(self): - """ScheduleTrigger should accept legacy schedule alias for cron.""" - trigger = ScheduleTrigger(schedule="0 */5 * * * *") - assert trigger.cron == "0 */5 * * * *" - assert trigger.schedule == "0 */5 * * * *" - - def test_requires_exactly_one_strategy(self): - """ScheduleTrigger must have exactly one of cron or interval_seconds.""" - # Neither provided should raise - with pytest.raises(ValidationError) as exc_info: - ScheduleTrigger() - assert "必须且只能有一个非 null" in str(exc_info.value) - - # Both provided should raise - with pytest.raises(ValidationError) as exc_info: - ScheduleTrigger(cron="* * * * *", interval_seconds=10) - assert "必须且只能有一个非 null" in str(exc_info.value) - - def test_valid_cron_expressions(self): - """ScheduleTrigger should accept various cron expressions.""" - trigger1 = ScheduleTrigger(cron="*/5 * * * *") - assert trigger1.cron == "*/5 * * * *" - - trigger2 = ScheduleTrigger(cron="0 0 1 1 *") - assert trigger2.cron == "0 0 1 1 *" - - def test_valid_intervals(self): - """ScheduleTrigger should accept various intervals.""" - trigger1 = ScheduleTrigger(interval_seconds=30) - assert trigger1.interval_seconds == 30 - - trigger2 = ScheduleTrigger(interval_seconds=3600) - assert trigger2.interval_seconds == 3600 - - -class TestHandlerDescriptor: - """Tests for HandlerDescriptor model.""" - - def test_required_id_and_trigger(self): - """HandlerDescriptor requires id and trigger.""" - trigger = CommandTrigger(command="hello") - handler = HandlerDescriptor(id="test.handler", trigger=trigger) - assert handler.id == "test.handler" - assert handler.trigger == trigger - assert handler.priority == 0 - assert handler.permissions.require_admin is False - - def test_with_priority_and_permissions(self): - """HandlerDescriptor should accept priority and permissions.""" - trigger = CommandTrigger(command="admin") - perms = Permissions(require_admin=True, level=5) - handler = HandlerDescriptor( - id="admin.handler", - trigger=trigger, - priority=10, - permissions=perms, - ) - assert handler.priority == 10 - assert handler.permissions.require_admin is True - assert handler.permissions.level == 5 - - def test_with_event_trigger(self): - """HandlerDescriptor should work with EventTrigger.""" - trigger = EventTrigger(event_type="message") - handler = HandlerDescriptor(id="event.handler", trigger=trigger) - assert handler.trigger.type == "event" - assert handler.trigger.event_type == "message" - - def test_with_schedule_trigger(self): - """HandlerDescriptor should work with ScheduleTrigger.""" - trigger = ScheduleTrigger(cron="0 9 * * *") - handler = HandlerDescriptor(id="scheduled.handler", trigger=trigger) - assert handler.trigger.type == "schedule" - assert handler.trigger.cron == "0 9 * * *" - - def test_model_dump(self): - """HandlerDescriptor should serialize correctly.""" - trigger = CommandTrigger(command="test", aliases=["t"]) - perms = Permissions(require_admin=True, level=5) - handler = HandlerDescriptor( - id="test.handler", - trigger=trigger, - priority=10, - permissions=perms, - ) - data = handler.model_dump() - assert data["id"] == "test.handler" - assert data["priority"] == 10 - assert data["trigger"]["type"] == "command" - assert data["trigger"]["command"] == "test" - assert data["permissions"]["require_admin"] is True - - def test_extra_fields_forbidden(self): - """HandlerDescriptor should forbid extra fields.""" - trigger = CommandTrigger(command="test") - with pytest.raises(ValidationError): - HandlerDescriptor(id="test", trigger=trigger, extra="field") - - def test_defaults_kind_and_contract_from_trigger(self): - """HandlerDescriptor should infer contract defaults from trigger shape.""" - message_handler = HandlerDescriptor( - id="msg.handler", - trigger=CommandTrigger(command="hello"), - ) - schedule_handler = HandlerDescriptor( - id="sched.handler", - trigger=ScheduleTrigger(interval_seconds=30), - ) - - assert message_handler.kind == "handler" - assert message_handler.contract == "message_event" - assert schedule_handler.contract == "schedule" - - -class TestSessionRef: - """Tests for SessionRef model.""" - - def test_accepts_legacy_session_alias(self): - """SessionRef should accept legacy session field while normalizing storage.""" - ref = SessionRef.model_validate({"session": "session-1", "platform": "qq"}) - - assert ref.conversation_id == "session-1" - assert ref.session == "session-1" - assert ref.platform == "qq" - - def test_schema_is_exported_for_platform_targets(self): - """SessionRef schema should remain a shared protocol constant.""" - assert SESSION_REF_SCHEMA["required"] == ["conversation_id"] - - -class TestCapabilityDescriptor: - """Tests for CapabilityDescriptor model.""" - - def test_required_name_and_description(self): - """CapabilityDescriptor requires name and description.""" - cap = CapabilityDescriptor(name="custom.chat", description="Chat with LLM") - assert cap.name == "custom.chat" - assert cap.description == "Chat with LLM" - assert cap.input_schema is None - assert cap.output_schema is None - assert cap.supports_stream is False - assert cap.cancelable is False - - def test_builtin_capability_requires_schemas(self): - """Built-in capabilities should enforce schema governance.""" - with pytest.raises(ValidationError, match="必须同时提供"): - CapabilityDescriptor(name="llm.chat", description="missing schemas") - - def test_builtin_capability_requires_registry_schema_match(self): - """Built-in capabilities should reject schemas that drift from protocol constants.""" - with pytest.raises(ValidationError, match="协议注册表保持一致"): - CapabilityDescriptor( - name="llm.chat", - description="wrong schema", - input_schema={"type": "object", "properties": {}, "required": []}, - output_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"], - ) - - def test_builtin_capability_schema_registry_contains_required_entries(self): - """Built-in schema registry should cover documented core capabilities.""" - assert "llm.chat" in BUILTIN_CAPABILITY_SCHEMAS - assert "db.list" in BUILTIN_CAPABILITY_SCHEMAS - assert LLM_CHAT_INPUT_SCHEMA["required"] == ["prompt"] - assert ( - DB_LIST_INPUT_SCHEMA["properties"]["prefix"]["anyOf"][1]["type"] == "null" - ) - - def test_reserved_capability_prefixes_are_protocol_constants(self): - """Reserved namespace prefixes should live in protocol descriptors.""" - assert RESERVED_CAPABILITY_PREFIXES == ("handler.", "system.", "internal.") - - def test_with_schemas(self): - """CapabilityDescriptor should accept input/output schemas.""" - cap = CapabilityDescriptor( - name="db.query", - description="Query database", - input_schema={"type": "object", "properties": {"sql": {"type": "string"}}}, - output_schema={"type": "array"}, - ) - assert cap.input_schema["type"] == "object" - assert cap.output_schema["type"] == "array" - - def test_with_stream_and_cancelable(self): - """CapabilityDescriptor should accept stream and cancelable flags.""" - cap = CapabilityDescriptor( - name="llm.stream", - description="Stream chat", - supports_stream=True, - cancelable=True, - ) - assert cap.supports_stream is True - assert cap.cancelable is True - - def test_model_dump(self): - """CapabilityDescriptor should serialize correctly.""" - cap = CapabilityDescriptor( - name="test.cap", - description="Test capability", - supports_stream=True, - ) - data = cap.model_dump() - assert data["name"] == "test.cap" - assert data["description"] == "Test capability" - assert data["supports_stream"] is True - - def test_extra_fields_forbidden(self): - """CapabilityDescriptor should forbid extra fields.""" - with pytest.raises(ValidationError): - CapabilityDescriptor( - name="test", - description="Test", - extra="field", - ) - - -class TestTriggerDiscriminator: - """Tests for Trigger type discriminator.""" - - def test_command_trigger_discrimination(self): - """CommandTrigger should be correctly discriminated.""" - handler = HandlerDescriptor( - id="cmd.handler", - trigger={"type": "command", "command": "test"}, - ) - assert isinstance(handler.trigger, CommandTrigger) - assert handler.trigger.command == "test" - - def test_message_trigger_discrimination(self): - """MessageTrigger should be correctly discriminated.""" - handler = HandlerDescriptor( - id="msg.handler", - trigger={"type": "message", "keywords": ["hello"]}, - ) - assert isinstance(handler.trigger, MessageTrigger) - assert handler.trigger.keywords == ["hello"] - - def test_event_trigger_discrimination(self): - """EventTrigger should be correctly discriminated.""" - handler = HandlerDescriptor( - id="evt.handler", - trigger={"type": "event", "event_type": "message"}, - ) - assert isinstance(handler.trigger, EventTrigger) - assert handler.trigger.event_type == "message" - - def test_schedule_trigger_discrimination(self): - """ScheduleTrigger should be correctly discriminated.""" - handler = HandlerDescriptor( - id="sched.handler", - trigger={"type": "schedule", "cron": "0 9 * * *"}, - ) - assert isinstance(handler.trigger, ScheduleTrigger) - assert handler.trigger.cron == "0 9 * * *" diff --git a/tests_v4/test_protocol_messages.py b/tests_v4/test_protocol_messages.py deleted file mode 100644 index 3d251b458..000000000 --- a/tests_v4/test_protocol_messages.py +++ /dev/null @@ -1,585 +0,0 @@ -""" -Tests for protocol/messages.py - Protocol message models. -""" - -from __future__ import annotations - -import copy -import json - -import pytest -from pydantic import ValidationError - -from astrbot_sdk.protocol.descriptors import ( - BUILTIN_CAPABILITY_SCHEMAS, - CapabilityDescriptor, - CommandTrigger, - HandlerDescriptor, -) -from astrbot_sdk.protocol.messages import ( - CancelMessage, - ErrorPayload, - EventMessage, - InitializeMessage, - InitializeOutput, - InvokeMessage, - PeerInfo, - ResultMessage, - parse_message, -) - - -class TestErrorPayload: - """Tests for ErrorPayload model.""" - - def test_required_code_and_message(self): - """ErrorPayload requires code and message.""" - error = ErrorPayload(code="test_error", message="Test error occurred") - assert error.code == "test_error" - assert error.message == "Test error occurred" - assert error.hint == "" - assert error.retryable is False - - def test_with_all_fields(self): - """ErrorPayload should accept all fields.""" - error = ErrorPayload( - code="server_error", - message="Internal server error", - hint="Try again later", - retryable=True, - ) - assert error.code == "server_error" - assert error.message == "Internal server error" - assert error.hint == "Try again later" - assert error.retryable is True - - def test_model_dump(self): - """ErrorPayload should serialize correctly.""" - error = ErrorPayload( - code="not_found", - message="Resource not found", - hint="Check the ID", - ) - data = error.model_dump() - assert data == { - "code": "not_found", - "message": "Resource not found", - "hint": "Check the ID", - "retryable": False, - } - - def test_extra_fields_forbidden(self): - """ErrorPayload should forbid extra fields.""" - with pytest.raises(ValidationError): - ErrorPayload(code="test", message="test", extra="field") - - -class TestPeerInfo: - """Tests for PeerInfo model.""" - - def test_required_name_and_role(self): - """PeerInfo requires name and role.""" - peer = PeerInfo(name="test-plugin", role="plugin") - assert peer.name == "test-plugin" - assert peer.role == "plugin" - assert peer.version is None - - def test_with_version(self): - """PeerInfo should accept version.""" - peer = PeerInfo(name="my-plugin", role="plugin", version="1.0.0") - assert peer.version == "1.0.0" - - def test_role_must_be_valid(self): - """PeerInfo role must be 'plugin' or 'core'.""" - peer1 = PeerInfo(name="p1", role="plugin") - assert peer1.role == "plugin" - - peer2 = PeerInfo(name="p2", role="core") - assert peer2.role == "core" - - with pytest.raises(ValidationError): - PeerInfo(name="p3", role="invalid") - - def test_model_dump(self): - """PeerInfo should serialize correctly.""" - peer = PeerInfo(name="test", role="plugin", version="2.0.0") - data = peer.model_dump() - assert data == {"name": "test", "role": "plugin", "version": "2.0.0"} - - def test_extra_fields_forbidden(self): - """PeerInfo should forbid extra fields.""" - with pytest.raises(ValidationError): - PeerInfo(name="test", role="plugin", extra="field") - - -class TestInitializeMessage: - """Tests for InitializeMessage model.""" - - def test_required_fields(self): - """InitializeMessage requires id, protocol_version, and peer.""" - peer = PeerInfo(name="test", role="plugin") - msg = InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer=peer, - ) - assert msg.type == "initialize" - assert msg.id == "msg_001" - assert msg.protocol_version == "1.0" - assert msg.peer == peer - assert msg.handlers == [] - assert msg.metadata == {} - - def test_with_handlers(self): - """InitializeMessage should accept handlers.""" - peer = PeerInfo(name="test", role="plugin") - handler = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - msg = InitializeMessage( - id="msg_002", - protocol_version="1.0", - peer=peer, - handlers=[handler], - ) - assert len(msg.handlers) == 1 - assert msg.handlers[0].id == "test.handler" - - def test_with_metadata(self): - """InitializeMessage should accept metadata.""" - peer = PeerInfo(name="test", role="plugin") - msg = InitializeMessage( - id="msg_003", - protocol_version="1.0", - peer=peer, - metadata={"author": "test", "version": "1.0.0"}, - ) - assert msg.metadata["author"] == "test" - assert msg.metadata["version"] == "1.0.0" - - def test_with_provided_capabilities(self): - """InitializeMessage should carry plugin-provided capabilities.""" - peer = PeerInfo(name="test", role="plugin") - capability = CapabilityDescriptor( - name="demo.echo", - description="Echo capability", - input_schema={"type": "object", "properties": {"text": {"type": "string"}}}, - output_schema={ - "type": "object", - "properties": {"echo": {"type": "string"}}, - }, - ) - msg = InitializeMessage( - id="msg_caps", - protocol_version="1.0", - peer=peer, - provided_capabilities=[capability], - ) - - assert [item.name for item in msg.provided_capabilities] == ["demo.echo"] - - def test_model_dump_json(self): - """InitializeMessage should serialize to JSON correctly.""" - peer = PeerInfo(name="test", role="plugin", version="1.0.0") - handler = HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - msg = InitializeMessage( - id="msg_004", - protocol_version="1.0", - peer=peer, - handlers=[handler], - metadata={"key": "value"}, - ) - json_str = msg.model_dump_json() - data = json.loads(json_str) - assert data["type"] == "initialize" - assert data["id"] == "msg_004" - assert data["peer"]["name"] == "test" - assert len(data["handlers"]) == 1 - - -class TestInitializeOutput: - """Tests for InitializeOutput model.""" - - def test_required_peer(self): - """InitializeOutput requires peer.""" - peer = PeerInfo(name="core", role="core") - output = InitializeOutput(peer=peer) - assert output.peer == peer - assert output.capabilities == [] - assert output.metadata == {} - - def test_with_capabilities(self): - """InitializeOutput should accept capabilities.""" - peer = PeerInfo(name="core", role="core") - cap = CapabilityDescriptor( - name="llm.chat", - description="Chat capability", - input_schema=copy.deepcopy(BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["input"]), - output_schema=copy.deepcopy( - BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"] - ), - ) - output = InitializeOutput(peer=peer, capabilities=[cap]) - assert len(output.capabilities) == 1 - assert output.capabilities[0].name == "llm.chat" - - def test_with_metadata(self): - """InitializeOutput should accept metadata.""" - peer = PeerInfo(name="core", role="core") - output = InitializeOutput(peer=peer, metadata={"session": "abc"}) - assert output.metadata["session"] == "abc" - - def test_with_protocol_version(self): - """InitializeOutput should accept negotiated protocol_version.""" - peer = PeerInfo(name="core", role="core") - output = InitializeOutput(peer=peer, protocol_version="1.0") - assert output.protocol_version == "1.0" - - -class TestResultMessage: - """Tests for ResultMessage model.""" - - def test_success_result(self): - """ResultMessage for success case.""" - msg = ResultMessage(id="msg_001", success=True, output={"text": "ok"}) - assert msg.type == "result" - assert msg.id == "msg_001" - assert msg.success is True - assert msg.output["text"] == "ok" - assert msg.error is None - assert msg.kind is None - - def test_error_result(self): - """ResultMessage for error case.""" - error = ErrorPayload(code="not_found", message="Resource not found") - msg = ResultMessage(id="msg_002", success=False, error=error) - assert msg.success is False - assert msg.error.code == "not_found" - assert msg.error.message == "Resource not found" - - def test_with_kind(self): - """ResultMessage should accept kind.""" - msg = ResultMessage( - id="msg_003", - kind="initialize_result", - success=True, - ) - assert msg.kind == "initialize_result" - - def test_default_output(self): - """ResultMessage should have empty dict as default output.""" - msg = ResultMessage(id="msg_004", success=True) - assert msg.output == {} - - def test_success_result_rejects_error(self): - """ResultMessage success=true should not accept error payload.""" - with pytest.raises(ValidationError) as exc_info: - ResultMessage( - id="msg_005", - success=True, - error=ErrorPayload(code="bad", message="bad"), - ) - assert "success=true 时 error 必须为空" in str(exc_info.value) - - def test_failed_result_requires_error(self): - """ResultMessage success=false should require error payload.""" - with pytest.raises(ValidationError) as exc_info: - ResultMessage(id="msg_006", success=False) - assert "success=false 时必须提供 error" in str(exc_info.value) - - def test_failed_result_rejects_output(self): - """ResultMessage success=false should not carry success output.""" - with pytest.raises(ValidationError) as exc_info: - ResultMessage( - id="msg_007", - success=False, - output={"text": "bad"}, - error=ErrorPayload(code="bad", message="bad"), - ) - assert "success=false 时 output 必须为空" in str(exc_info.value) - - -class TestInvokeMessage: - """Tests for InvokeMessage model.""" - - def test_required_fields(self): - """InvokeMessage requires id and capability.""" - msg = InvokeMessage(id="msg_001", capability="llm.chat") - assert msg.type == "invoke" - assert msg.id == "msg_001" - assert msg.capability == "llm.chat" - assert msg.input == {} - assert msg.stream is False - - def test_with_input(self): - """InvokeMessage should accept input payload.""" - msg = InvokeMessage( - id="msg_002", - capability="db.get", - input={"key": "user:123"}, - ) - assert msg.input["key"] == "user:123" - - def test_with_stream(self): - """InvokeMessage should accept stream flag.""" - msg = InvokeMessage( - id="msg_003", - capability="llm.stream", - input={"prompt": "hello"}, - stream=True, - ) - assert msg.stream is True - - def test_model_dump(self): - """InvokeMessage should serialize correctly.""" - msg = InvokeMessage( - id="msg_004", - capability="test.cap", - input={"data": "value"}, - stream=True, - ) - data = msg.model_dump() - assert data["type"] == "invoke" - assert data["capability"] == "test.cap" - assert data["input"] == {"data": "value"} - assert data["stream"] is True - - -class TestEventMessage: - """Tests for EventMessage model.""" - - def test_started_phase(self): - """EventMessage with started phase.""" - msg = EventMessage(id="msg_001", phase="started") - assert msg.type == "event" - assert msg.phase == "started" - assert msg.data == {} - assert msg.output == {} - assert msg.error is None - - def test_delta_phase(self): - """EventMessage with delta phase and data.""" - msg = EventMessage( - id="msg_002", - phase="delta", - data={"text": "chunk"}, - ) - assert msg.phase == "delta" - assert msg.data["text"] == "chunk" - - def test_completed_phase(self): - """EventMessage with completed phase.""" - msg = EventMessage( - id="msg_003", - phase="completed", - output={"result": "done"}, - ) - assert msg.phase == "completed" - assert msg.output["result"] == "done" - - def test_failed_phase(self): - """EventMessage with failed phase.""" - error = ErrorPayload(code="runtime_error", message="Failed") - msg = EventMessage( - id="msg_004", - phase="failed", - error=error, - ) - assert msg.phase == "failed" - assert msg.error.code == "runtime_error" - - def test_invalid_phase(self): - """EventMessage should reject invalid phase.""" - with pytest.raises(ValidationError): - EventMessage(id="msg_005", phase="invalid") - - -class TestCancelMessage: - """Tests for CancelMessage model.""" - - def test_default_reason(self): - """CancelMessage should have default reason.""" - msg = CancelMessage(id="msg_001") - assert msg.type == "cancel" - assert msg.id == "msg_001" - assert msg.reason == "user_cancelled" - - def test_custom_reason(self): - """CancelMessage should accept custom reason.""" - msg = CancelMessage(id="msg_002", reason="timeout") - assert msg.reason == "timeout" - - def test_model_dump(self): - """CancelMessage should serialize correctly.""" - msg = CancelMessage(id="msg_003", reason="user_request") - data = msg.model_dump() - assert data == { - "type": "cancel", - "id": "msg_003", - "reason": "user_request", - } - - -class TestParseMessage: - """Tests for parse_message function.""" - - def test_parse_initialize_from_dict(self): - """parse_message should parse InitializeMessage from dict.""" - data = { - "type": "initialize", - "id": "msg_001", - "protocol_version": "1.0", - "peer": {"name": "test", "role": "plugin"}, - } - msg = parse_message(data) - assert isinstance(msg, InitializeMessage) - assert msg.id == "msg_001" - assert msg.peer.name == "test" - - def test_parse_result_from_dict(self): - """parse_message should parse ResultMessage from dict.""" - data = { - "type": "result", - "id": "msg_002", - "success": True, - "output": {"text": "ok"}, - } - msg = parse_message(data) - assert isinstance(msg, ResultMessage) - assert msg.success is True - - def test_parse_invoke_from_dict(self): - """parse_message should parse InvokeMessage from dict.""" - data = { - "type": "invoke", - "id": "msg_003", - "capability": "test.cap", - "input": {"key": "value"}, - } - msg = parse_message(data) - assert isinstance(msg, InvokeMessage) - assert msg.capability == "test.cap" - - def test_parse_event_from_dict(self): - """parse_message should parse EventMessage from dict.""" - data = { - "type": "event", - "id": "msg_004", - "phase": "delta", - "data": {"text": "chunk"}, - } - msg = parse_message(data) - assert isinstance(msg, EventMessage) - assert msg.phase == "delta" - - def test_parse_cancel_from_dict(self): - """parse_message should parse CancelMessage from dict.""" - data = { - "type": "cancel", - "id": "msg_005", - "reason": "user_request", - } - msg = parse_message(data) - assert isinstance(msg, CancelMessage) - assert msg.reason == "user_request" - - def test_parse_from_json_string(self): - """parse_message should parse from JSON string.""" - json_str = '{"type": "invoke", "id": "msg_006", "capability": "test"}' - msg = parse_message(json_str) - assert isinstance(msg, InvokeMessage) - assert msg.capability == "test" - - def test_parse_from_bytes(self): - """parse_message should parse from bytes.""" - json_bytes = b'{"type": "result", "id": "msg_007", "success": true}' - msg = parse_message(json_bytes) - assert isinstance(msg, ResultMessage) - assert msg.success is True - - def test_parse_pass_through_model(self): - """parse_message should return already-parsed protocol models unchanged.""" - original = InvokeMessage(id="msg_008", capability="test.cap") - assert parse_message(original) is original - - def test_parse_non_mapping_raises(self): - """parse_message should reject non-object payloads.""" - with pytest.raises(ValueError, match="JSON object"): - parse_message(["not", "an", "object"]) - - def test_parse_unknown_type_raises(self): - """parse_message should raise for unknown type.""" - with pytest.raises(ValueError) as exc_info: - parse_message({"type": "unknown"}) - assert "未知消息类型" in str(exc_info.value) - - def test_roundtrip_serialize_deserialize(self): - """Message should survive serialize/deserialize roundtrip.""" - original = InitializeMessage( - id="msg_008", - protocol_version="1.0", - peer=PeerInfo(name="test", role="plugin", version="1.0.0"), - handlers=[ - HandlerDescriptor( - id="test.handler", - trigger=CommandTrigger(command="hello"), - ) - ], - metadata={"key": "value"}, - ) - json_str = original.model_dump_json() - parsed = parse_message(json_str) - assert isinstance(parsed, InitializeMessage) - assert parsed.id == original.id - assert parsed.peer.name == original.peer.name - assert len(parsed.handlers) == 1 - - -class TestMessageExtraForbidden: - """Tests for extra field rejection across all message types.""" - - def test_initialize_extra_forbidden(self): - """InitializeMessage should reject extra fields.""" - with pytest.raises(ValidationError): - InitializeMessage( - id="msg_001", - protocol_version="1.0", - peer={"name": "test", "role": "plugin"}, - extra="field", - ) - - def test_result_extra_forbidden(self): - """ResultMessage should reject extra fields.""" - with pytest.raises(ValidationError): - ResultMessage( - id="msg_001", - success=True, - extra="field", - ) - - def test_invoke_extra_forbidden(self): - """InvokeMessage should reject extra fields.""" - with pytest.raises(ValidationError): - InvokeMessage( - id="msg_001", - capability="test", - extra="field", - ) - - def test_event_extra_forbidden(self): - """EventMessage should reject extra fields.""" - with pytest.raises(ValidationError): - EventMessage( - id="msg_001", - phase="started", - extra="field", - ) - - def test_cancel_extra_forbidden(self): - """CancelMessage should reject extra fields.""" - with pytest.raises(ValidationError): - CancelMessage(id="msg_001", extra="field") diff --git a/tests_v4/test_testing_module.py b/tests_v4/test_testing_module.py deleted file mode 100644 index f7dd55590..000000000 --- a/tests_v4/test_testing_module.py +++ /dev/null @@ -1,315 +0,0 @@ -from __future__ import annotations - -import asyncio -import io -import os -import subprocess -import sys -from pathlib import Path - -import pytest - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[1] - - -def _source_env() -> dict[str, str]: - env = os.environ.copy() - src_new = str(_repo_root() / "src-new") - current = env.get("PYTHONPATH") - env["PYTHONPATH"] = f"{src_new}{os.pathsep}{current}" if current else src_new - return env - - -def test_testing_module_importable() -> None: - from astrbot_sdk import testing - - assert testing.PluginHarness is not None - assert testing.MockContext is not None - - -def test_cli_help_works_from_source_tree() -> None: - process = subprocess.run( - [sys.executable, "-m", "astrbot_sdk", "--help"], - capture_output=True, - text=True, - check=False, - env=_source_env(), - ) - - assert process.returncode == 0, process.stderr - assert "Usage" in process.stdout - - -def test_dev_help_lists_watch_option() -> None: - process = subprocess.run( - [sys.executable, "-m", "astrbot_sdk", "dev", "--help"], - capture_output=True, - text=True, - check=False, - env=_source_env(), - ) - - assert process.returncode == 0, process.stderr - assert "--watch" in process.stdout - - -def test_init_plugin_template_includes_readme(tmp_path: Path, monkeypatch) -> None: - from astrbot_sdk.cli import _init_plugin - - target = tmp_path / "astrbot_plugin_demo_plugin" - monkeypatch.chdir(tmp_path) - - _init_plugin("demo_plugin") - - assert (target / "README.md").exists() - readme = (target / "README.md").read_text(encoding="utf-8") - test_file = (target / "tests" / "test_plugin.py").read_text(encoding="utf-8") - - assert "astrbot-sdk dev --local --watch --event-text hello" in readme - assert "PluginHarness.from_plugin_dir" in test_file - assert "test_hello_dispatch" in test_file - - -def test_mock_context_accepts_plugin_metadata() -> None: - from astrbot_sdk.testing import MockContext - - ctx = MockContext( - plugin_id="demo_plugin", - plugin_metadata={ - "display_name": "Demo Plugin", - "author": "tester", - "version": "1.2.3", - }, - ) - - plugin = ctx.router._plugins["demo_plugin"].metadata - assert plugin["display_name"] == "Demo Plugin" - assert plugin["author"] == "tester" - assert plugin["version"] == "1.2.3" - - -def test_plugin_harness_from_plugin_dir_builds_expected_config() -> None: - from astrbot_sdk.testing import PluginHarness - - plugin_dir = _repo_root() / "examples" / "hello_plugin" - - harness = PluginHarness.from_plugin_dir( - plugin_dir, - session_id="custom-session", - platform="qq", - ) - - assert harness.config.plugin_dir == plugin_dir - assert harness.config.session_id == "custom-session" - assert harness.config.platform == "qq" - - -@pytest.mark.asyncio -async def test_plugin_harness_dispatches_sample_plugin() -> None: - from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness - - plugin_dir = _repo_root() / "test_plugin" / "new" - - async with PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) as harness: - records = await harness.dispatch_text("hello") - - assert any(record.text == "Echo: hello" for record in records) - - -@pytest.mark.asyncio -async def test_plugin_harness_supports_metadata_and_http_commands() -> None: - from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness - - plugin_dir = _repo_root() / "test_plugin" / "new" - - async with PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) as harness: - plugin_records = await harness.dispatch_text("plugins") - api_records = await harness.dispatch_text("register_api") - - assert any( - "astrbot_plugin_v4demo" in (record.text or "") for record in plugin_records - ) - assert any( - "已注册 API,当前共 1 个" in (record.text or "") for record in api_records - ) - - -@pytest.mark.asyncio -async def test_example_hello_plugin_dispatches_commands() -> None: - from astrbot_sdk.testing import PluginHarness - - plugin_dir = _repo_root() / "examples" / "hello_plugin" - - async with PluginHarness.from_plugin_dir(plugin_dir) as harness: - hello_records = await harness.dispatch_text("hello") - about_records = await harness.dispatch_text("about") - - assert any(record.text == "Hello, World!" for record in hello_records) - # about 命令返回 display_name "Hello Plugin",不是 name "hello_plugin" - assert any("Hello Plugin" in (record.text or "") for record in about_records) - - -def test_dev_infers_plugin_dir_from_current_directory() -> None: - plugin_dir = _repo_root() / "examples" / "hello_plugin" - process = subprocess.run( - [ - sys.executable, - "-m", - "astrbot_sdk", - "dev", - "--local", - "--event-text", - "hello", - ], - capture_output=True, - text=True, - check=False, - env=_source_env(), - cwd=plugin_dir, - ) - - assert process.returncode == 0, process.stderr - assert "[text][local-session] Hello, World!" in process.stdout - - -def test_dev_requires_plugin_dir_or_plugin_yaml_in_cwd(tmp_path: Path) -> None: - process = subprocess.run( - [ - sys.executable, - "-m", - "astrbot_sdk", - "dev", - "--local", - "--event-text", - "hello", - ], - capture_output=True, - text=True, - check=False, - env=_source_env(), - cwd=tmp_path, - ) - - assert process.returncode != 0 - assert "当前目录未找到 plugin.yaml" in process.stderr - - -@pytest.mark.asyncio -async def test_plugin_harness_reports_component_load_errors(tmp_path: Path) -> None: - from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness, _PluginLoadError - - plugin_dir = tmp_path / "broken-plugin" - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (plugin_dir / "plugin.yaml").write_text( - "\n".join( - [ - "name: broken_demo", - "display_name: Broken Demo", - "author: test", - "version: 0.1.0", - "runtime:", - ' python: "3.13"', - "components:", - " - class: main:MissingComponent", - ] - ), - encoding="utf-8", - ) - (plugin_dir / "main.py").write_text( - "\n".join( - [ - "from astrbot_sdk import Star", - "", - "class PresentComponent(Star):", - " pass", - "", - ] - ), - encoding="utf-8", - ) - - harness = PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) - with pytest.raises(_PluginLoadError) as raised: - await harness.start() - message = str(raised.value) - assert "插件 'broken_demo'" in message - assert "components[0].class='main:MissingComponent'" in message - assert "加载失败" in message - - -def _write_watch_plugin(plugin_dir: Path, *, reply_text: str) -> None: - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "requirements.txt").write_text("", encoding="utf-8") - (plugin_dir / "plugin.yaml").write_text( - "\n".join( - [ - "name: watch_demo", - "display_name: Watch Demo", - "author: test", - "version: 0.1.0", - "runtime:", - ' python: "3.13"', - "components:", - " - class: main:WatchDemo", - ] - ), - encoding="utf-8", - ) - (plugin_dir / "main.py").write_text( - "\n".join( - [ - "from astrbot_sdk import Context, MessageEvent, Star, on_command", - "", - "class WatchDemo(Star):", - ' @on_command("hello")', - " async def hello(self, event: MessageEvent, ctx: Context) -> None:", - f' await event.reply("{reply_text}")', - "", - ] - ), - encoding="utf-8", - ) - - -@pytest.mark.asyncio -async def test_run_local_dev_watch_reloads_on_file_change( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from astrbot_sdk.cli import _run_local_dev - - plugin_dir = tmp_path / "watch-plugin" - _write_watch_plugin(plugin_dir, reply_text="v1") - - stdout = io.StringIO() - monkeypatch.setattr(sys, "stdout", stdout) - - task = asyncio.create_task( - _run_local_dev( - plugin_dir=plugin_dir, - event_text="hello", - interactive=False, - watch=True, - session_id="local-session", - user_id="local-user", - platform="test", - group_id=None, - event_type="message", - watch_poll_interval=0.05, - max_watch_reloads=1, - ) - ) - - await asyncio.sleep(0.2) - _write_watch_plugin(plugin_dir, reply_text="v2") - - await asyncio.wait_for(task, timeout=3.0) - - output = stdout.getvalue() - assert "watch 模式已启动" in output - assert "检测到文件变更" in output - assert "[text][local-session] v1" in output - assert "[text][local-session] v2" in output diff --git a/tests_v4/test_top_level_modules.py b/tests_v4/test_top_level_modules.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests_v4/test_transport.py b/tests_v4/test_transport.py deleted file mode 100644 index 0251e2630..000000000 --- a/tests_v4/test_transport.py +++ /dev/null @@ -1,587 +0,0 @@ -""" -Tests for runtime/transport.py - Transport implementations. -""" - -from __future__ import annotations - -import asyncio -from io import BytesIO, StringIO -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from astrbot_sdk.runtime.transport import ( - StdioTransport, - Transport, - WebSocketClientTransport, - WebSocketServerTransport, -) - - -class TestTransportBase: - """Tests for Transport base class.""" - - def test_init_sets_handler_none(self): - """Transport should initialize with _handler as None.""" - - # 创建一个具体的测试子类 - class ConcreteTransport(Transport): - async def start(self): - pass - - async def stop(self): - pass - - async def send(self, payload): - pass - - transport = ConcreteTransport() - assert transport._handler is None - - def test_set_message_handler(self): - """set_message_handler should store handler.""" - - class ConcreteTransport(Transport): - async def start(self): - pass - - async def stop(self): - pass - - async def send(self, payload): - pass - - transport = ConcreteTransport() - handler = MagicMock() - transport.set_message_handler(handler) - assert transport._handler is handler - - @pytest.mark.asyncio - async def test_start_not_implemented(self): - """Transport.start should be abstract.""" - # 抽象方法不能直接测试,跳过 - pass - - @pytest.mark.asyncio - async def test_stop_not_implemented(self): - """Transport.stop should be abstract.""" - # 抽象方法不能直接测试,跳过 - pass - - @pytest.mark.asyncio - async def test_send_not_implemented(self): - """Transport.send should be abstract.""" - # 抽象方法不能直接测试,跳过 - pass - - @pytest.mark.asyncio - async def test_wait_closed(self): - """wait_closed should wait for _closed event.""" - - class ConcreteTransport(Transport): - async def start(self): - pass - - async def stop(self): - pass - - async def send(self, payload): - pass - - transport = ConcreteTransport() - transport._closed.set() - # Should return immediately since _closed is already set - await transport.wait_closed() - - @pytest.mark.asyncio - async def test_dispatch_calls_handler(self): - """_dispatch should call handler with payload.""" - - class ConcreteTransport(Transport): - async def start(self): - pass - - async def stop(self): - pass - - async def send(self, payload): - pass - - transport = ConcreteTransport() - handler = AsyncMock() - transport.set_message_handler(handler) - await transport._dispatch(b"test payload") - handler.assert_called_once_with(b"test payload") - - @pytest.mark.asyncio - async def test_dispatch_without_handler(self): - """_dispatch should work without handler.""" - - class ConcreteTransport(Transport): - async def start(self): - pass - - async def stop(self): - pass - - async def send(self, payload): - pass - - transport = ConcreteTransport() - # Should not raise when no handler is set - await transport._dispatch(b"test payload") - - -class TestStdioTransportInit: - """Tests for StdioTransport initialization.""" - - def test_default_init(self): - """StdioTransport should initialize with default values.""" - transport = StdioTransport() - assert transport._stdin is None - assert transport._stdout is None - assert transport._command is None - assert transport._cwd is None - assert transport._env is None - assert transport._process is None - assert transport._reader_task is None - - def test_with_custom_streams(self): - """StdioTransport should accept custom stdin/stdout.""" - stdin = StringIO() - stdout = StringIO() - transport = StdioTransport(stdin=stdin, stdout=stdout) - assert transport._stdin is stdin - assert transport._stdout is stdout - - def test_with_command(self): - """StdioTransport should accept command for subprocess.""" - transport = StdioTransport(command=["python", "-m", "module"]) - assert transport._command == ["python", "-m", "module"] - - def test_with_cwd_and_env(self): - """StdioTransport should accept cwd and env.""" - transport = StdioTransport(cwd="/tmp", env={"VAR": "value"}) - assert transport._cwd == "/tmp" - assert transport._env == {"VAR": "value"} - - -class TestStdioTransportFileMode: - """Tests for StdioTransport in file mode (no subprocess).""" - - @pytest.mark.asyncio - async def test_start_without_command(self): - """start() without command should use stdin/stdout.""" - transport = StdioTransport() - with patch("sys.stdin"), patch("sys.stdout"): - await transport.start() - assert transport._reader_task is not None - await transport.stop() - - @pytest.mark.asyncio - async def test_stop_cancels_reader_task(self): - """stop() should cancel reader task.""" - transport = StdioTransport() - with patch("sys.stdin"), patch("sys.stdout"): - await transport.start() - task = transport._reader_task - await transport.stop() - assert task is not None - assert task.cancelled() or task.done() - - @pytest.mark.asyncio - async def test_send_without_process(self): - """send() without process should write to stdout.""" - stdout = StringIO() - transport = StdioTransport(stdout=stdout) - - with patch("sys.stdin"): - await transport.start() - - await transport.send("test message") - - # Should have written the message with newline - assert stdout.getvalue() == "test message\n" - - await transport.stop() - - @pytest.mark.asyncio - async def test_send_adds_newline_if_missing(self): - """send() should add newline if not present.""" - stdout = StringIO() - transport = StdioTransport(stdout=stdout) - - with patch("sys.stdin"): - await transport.start() - - await transport.send("message") - assert stdout.getvalue() == "message\n" - - await transport.stop() - - @pytest.mark.asyncio - async def test_send_preserves_existing_newline(self): - """send() should not add extra newline.""" - stdout = StringIO() - transport = StdioTransport(stdout=stdout) - - with patch("sys.stdin"): - await transport.start() - - await transport.send("message\n") - assert stdout.getvalue() == "message\n" - - await transport.stop() - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "payload", - ["first\nsecond", "first\rsecond", "first\r\nsecond"], - ) - async def test_send_rejects_embedded_newlines(self, payload): - """send() should reject payloads containing raw embedded newlines.""" - stdout = MagicMock() - stdout.write = MagicMock() - stdout.flush = MagicMock() - transport = StdioTransport(stdout=stdout) - - with patch("sys.stdin"): - await transport.start() - - with pytest.raises(ValueError, match="原始换行符"): - await transport.send(payload) - stdout.write.assert_not_called() - - await transport.stop() - - @pytest.mark.asyncio - async def test_send_raises_without_stdout(self): - """send() should raise if stdout is None.""" - transport = StdioTransport(stdout=None) - transport._stdout = None - - with pytest.raises(RuntimeError, match="stdout"): - await transport.send("test") - - @pytest.mark.asyncio - async def test_length_prefixed_roundtrip(self): - """length-prefixed stdio should preserve binary payloads.""" - stdin = BytesIO() - stdout = BytesIO() - transport = StdioTransport( - stdin=stdin, - stdout=stdout, - framing="length_prefixed", - ) - received: list[bytes] = [] - - async def handle_message(payload: bytes): - received.append(payload) - - transport.set_message_handler(handle_message) - await transport.send(b"\x81\xa4test\x01") - stdin.write(stdout.getvalue()) - stdin.seek(0) - await transport._read_file_loop() - assert received == [b"\x81\xa4test\x01"] - - -class TestStdioTransportProcessMode: - """Tests for StdioTransport in subprocess mode.""" - - @pytest.mark.asyncio - async def test_start_with_command_creates_process(self): - """start() with command should create subprocess.""" - # 使用 Python 解释器作为跨平台兼容的命令 - import sys - - transport = StdioTransport(command=[sys.executable, "-c", "print('test')"]) - - await transport.start() - assert transport._process is not None - assert transport._reader_task is not None - - await transport.stop() - - @pytest.mark.asyncio - async def test_stop_terminates_process(self): - """stop() should terminate the subprocess.""" - import sys - - # 使用 Python 长时间运行的脚本替代 sleep - transport = StdioTransport( - command=[sys.executable, "-c", "import time; time.sleep(100)"] - ) - - await transport.start() - process = transport._process - assert process is not None - - await transport.stop() - assert process.returncode is not None - - @pytest.mark.asyncio - async def test_send_to_process(self): - """send() should write to process stdin.""" - import sys - - # 使用 Python 脚本替代 cat,读取 stdin 并输出 - transport = StdioTransport( - command=[ - sys.executable, - "-c", - "import sys; sys.stdout.write(sys.stdin.read())", - ] - ) - - await transport.start() - # Should not raise - await transport.send("test data") - - await transport.stop() - - @pytest.mark.asyncio - async def test_send_raises_if_process_stdin_none(self): - """send() should raise if process stdin is None.""" - import sys - - transport = StdioTransport( - command=[ - sys.executable, - "-c", - "import sys; sys.stdout.write(sys.stdin.read())", - ] - ) - await transport.start() - - # Manually set stdin to None to simulate error condition - if transport._process: - transport._process.stdin = None # type: ignore - - with pytest.raises(RuntimeError, match="stdin"): - await transport.send("test") - - await transport.stop() - - -class TestWebSocketServerTransportInit: - """Tests for WebSocketServerTransport initialization.""" - - def test_default_init(self): - """WebSocketServerTransport should have default values.""" - transport = WebSocketServerTransport() - assert transport._host == "127.0.0.1" - assert transport._port == 8765 - assert transport._path == "/" - assert transport._heartbeat == 30.0 - assert transport._app is None - assert transport._ws is None - - def test_custom_values(self): - """WebSocketServerTransport should accept custom values.""" - transport = WebSocketServerTransport( - host="0.0.0.0", - port=9000, - path="/ws", - heartbeat=60.0, - ) - assert transport._host == "0.0.0.0" - assert transport._port == 9000 - assert transport._path == "/ws" - assert transport._heartbeat == 60.0 - - def test_port_property_returns_actual_port(self): - """port property should return actual port after start.""" - transport = WebSocketServerTransport(port=8765) - # Before start, should return configured port - assert transport.port == 8765 - - def test_url_property(self): - """url property should return WebSocket URL.""" - transport = WebSocketServerTransport(host="localhost", port=8080, path="/ws") - assert transport.url == "ws://localhost:8080/ws" - - -class TestWebSocketServerTransportLifecycle: - """Tests for WebSocketServerTransport lifecycle.""" - - @pytest.mark.asyncio - async def test_start_creates_app(self): - """start() should create aiohttp app.""" - transport = WebSocketServerTransport(port=0) - await transport.start() - - assert transport._app is not None - assert transport._runner is not None - assert transport._site is not None - - await transport.stop() - - @pytest.mark.asyncio - async def test_stop_closes_websocket(self): - """stop() should close the WebSocket.""" - transport = WebSocketServerTransport(port=0) - await transport.start() - await transport.stop() - - assert transport._ws is None - assert transport._runner is None - - @pytest.mark.asyncio - async def test_send_waits_for_connection(self): - """send() should wait for WebSocket connection.""" - transport = WebSocketServerTransport(port=0) - await transport.start() - - # Mock connected state - transport._connected.set() - # _ws 需要有异步的 send_str 方法 - transport._ws = MagicMock() - transport._ws.closed = False - transport._ws.send_str = AsyncMock() - transport._ws.send_bytes = AsyncMock() - # close 也需要是异步的 - transport._ws.close = AsyncMock() - - await transport.send("test") - transport._ws.send_str.assert_called_once_with("test") - - await transport.stop() - - @pytest.mark.asyncio - async def test_send_raises_if_not_connected(self): - """send() should raise if WebSocket not connected.""" - transport = WebSocketServerTransport(port=0, heartbeat=0) - await transport.start() - - # Set timeout to 0 for immediate failure - with pytest.raises((RuntimeError, asyncio.TimeoutError)): - await transport.send("test") - - await transport.stop() - - @pytest.mark.asyncio - async def test_send_binary_frame_when_configured(self): - transport = WebSocketServerTransport(port=0, heartbeat=0, frame_type="binary") - await transport.start() - - transport._connected.set() - transport._ws = MagicMock() - transport._ws.closed = False - transport._ws.send_str = AsyncMock() - transport._ws.send_bytes = AsyncMock() - transport._ws.close = AsyncMock() - - await transport.send(b"\x81\xa3hi") - transport._ws.send_bytes.assert_called_once_with(b"\x81\xa3hi") - transport._ws.send_str.assert_not_called() - - await transport.stop() - - -class TestWebSocketClientTransportInit: - """Tests for WebSocketClientTransport initialization.""" - - def test_required_url(self): - """WebSocketClientTransport requires url.""" - transport = WebSocketClientTransport(url="ws://localhost:8080") - assert transport._url == "ws://localhost:8080" - assert transport._heartbeat == 30.0 - - def test_custom_heartbeat(self): - """WebSocketClientTransport should accept custom heartbeat.""" - transport = WebSocketClientTransport(url="ws://localhost:8080", heartbeat=60.0) - assert transport._heartbeat == 60.0 - - -class TestWebSocketClientTransportLifecycle: - """Tests for WebSocketClientTransport lifecycle.""" - - @pytest.mark.asyncio - async def test_start_creates_session(self): - """start() should create aiohttp session and connect.""" - server = WebSocketServerTransport(port=0) - await server.start() - - client = WebSocketClientTransport(url=server.url) - await client.start() - - assert client._session is not None - assert client._ws is not None - - await client.stop() - await server.stop() - - @pytest.mark.asyncio - async def test_stop_closes_session_and_websocket(self): - """stop() should close session and WebSocket.""" - server = WebSocketServerTransport(port=0) - await server.start() - - client = WebSocketClientTransport(url=server.url) - await client.start() - await client.stop() - - assert client._session is None - assert client._ws is None - - await server.stop() - - @pytest.mark.asyncio - async def test_send_after_start(self): - """send() should work after start().""" - server = WebSocketServerTransport(port=0) - await server.start() - - client = WebSocketClientTransport(url=server.url) - await client.start() - - # Should not raise - await client.send("test message") - - await client.stop() - await server.stop() - - @pytest.mark.asyncio - async def test_send_raises_if_not_connected(self): - """send() should raise if WebSocket not connected.""" - client = WebSocketClientTransport(url="ws://localhost:99999") - - with pytest.raises(RuntimeError, match="尚未连接"): - await client.send("test") - - -class TestTransportIntegration: - """Integration tests for transport pairs.""" - - @pytest.mark.asyncio - async def test_websocket_client_server_communication(self): - """WebSocket client and server should communicate.""" - server = WebSocketServerTransport(port=0) - client = WebSocketClientTransport(url="ws://invalid") - - received_messages = [] - - async def handle_message(payload: bytes): - received_messages.append(payload) - - server.set_message_handler(handle_message) - - await server.start() - - # Create new client with correct URL - client = WebSocketClientTransport(url=server.url) - await client.start() - - # Wait for connection - await asyncio.sleep(0.1) - - await client.send("hello from client") - - # Wait for message to be received - await asyncio.sleep(0.1) - - assert b"hello from client" in received_messages - - await client.stop() - await server.stop() diff --git a/tests_v4/test_wire_codecs.py b/tests_v4/test_wire_codecs.py deleted file mode 100644 index cacbac042..000000000 --- a/tests_v4/test_wire_codecs.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import pytest - -from astrbot_sdk.protocol.descriptors import CommandTrigger, HandlerDescriptor -from astrbot_sdk.protocol.messages import InitializeMessage, PeerInfo -from astrbot_sdk.protocol.wire_codecs import ( - JsonProtocolCodec, - MsgpackProtocolCodec, - make_protocol_codec, -) - - -def _sample_initialize_message() -> InitializeMessage: - return InitializeMessage( - id="msg-1", - protocol_version="1.0", - peer=PeerInfo(name="plugin", role="plugin", version="v4"), - handlers=[ - HandlerDescriptor( - id="plugin:hello", - trigger=CommandTrigger(command="hello"), - ) - ], - metadata={"plugin_id": "plugin", "loaded_plugins": ["plugin"]}, - ) - - -class TestJsonProtocolCodec: - def test_roundtrip(self): - codec = JsonProtocolCodec() - message = _sample_initialize_message() - - encoded = codec.encode_message(message) - decoded = codec.decode_message(encoded) - - assert isinstance(encoded, str) - assert decoded == message - - -class TestMsgpackProtocolCodec: - def test_roundtrip(self): - codec = MsgpackProtocolCodec() - message = _sample_initialize_message() - - encoded = codec.encode_message(message) - decoded = codec.decode_message(encoded) - - assert isinstance(encoded, bytes) - assert decoded == message - - -def test_make_protocol_codec_rejects_unknown_name(): - with pytest.raises(ValueError, match="未知 wire codec"): - make_protocol_codec("yaml")