mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-18 18:10:37 +08:00
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.
This commit is contained in:
29
README.md
Normal file
29
README.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
56
run_tests.py
56
run_tests.py
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
@@ -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]
|
||||
@@ -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`
|
||||
@@ -1 +0,0 @@
|
||||
__all__: list[str] = []
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user