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:
whatevertogo
2026-03-17 21:57:14 +08:00
parent b0e4d038ef
commit 6449167726
118 changed files with 54 additions and 8129 deletions

29
README.md Normal file
View 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
```

View File

@@ -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

View File

@@ -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())

View File

@@ -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()

View File

@@ -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,

View File

@@ -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]

View File

@@ -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`

View File

@@ -1 +0,0 @@
__all__: list[str] = []

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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