Files
AstrBot/tests_v4/test_handler_dispatcher.py
whatevertogo cdf72b75a4 feat: 实现 v4 API 兼容层,支持旧版插件导入路径
新增模块:
- api/basic/: AstrBotConfig, BaseConversationManager, Conversation
- api/event/: AstrMessageEvent, AstrBotMessage, EventType, MessageType,
  MessageSession, EventResult 等核心事件类型
- api/message/: MessageChain 及所有消息组件 (Plain, At, Image, File 等)
- api/platform/: PlatformMetadata 平台元数据类型
- api/provider/: LLMResponse 提供者响应实体
- api/star/star.py: StarMetadata 插件元数据类型

更新模块:
- api/__init__.py: 导出所有子模块
- api/components/: 扩展 Command 兼容性
- api/event/filter.py: 增强 filter 装饰器兼容性
- context.py, decorators.py, events.py: 顶层兼容入口

测试更新:
- test_api_event_filter.py: 验证 filter 兼容性
- test_api_modules.py: 验证新模块可导入
- test_handler_dispatcher.py: 验证处理器分发

文档更新:
- AGENTS.md/CLAUDE.md: 添加兼容层设计说明,避免重复造轮子

设计原则:
- 兼容层通过 thin re-export 方式暴露旧版 API
- 不复制独立运行时逻辑,保持架构清晰
- 新版推荐使用顶层模块导入路径
2026-03-13 03:17:03 +08:00

823 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for runtime/handler_dispatcher.py - HandlerDispatcher implementation.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from astrbot_sdk.api.event import AstrMessageEvent
from astrbot_sdk.context import CancelToken, Context
from astrbot_sdk.events import MessageEvent, PlainTextResult
from astrbot_sdk.protocol.descriptors import (
CommandTrigger,
HandlerDescriptor,
)
from astrbot_sdk.protocol.messages import InvokeMessage
from astrbot_sdk.runtime.handler_dispatcher import HandlerDispatcher
from astrbot_sdk.runtime.loader import LoadedHandler
class MockPeer:
"""Mock peer for testing."""
def __init__(self):
self.sent_messages: list[dict[str, Any]] = []
self.platform = self # platform.send 通过 self.send 调用
# CapabilityProxy 需要的属性
self.remote_capability_map: dict[str, Any] = {}
async def send(self, session_id: str, text: str) -> None:
"""模拟 platform.send 方法"""
self.sent_messages.append({"session_id": session_id, "text": text})
async def invoke(
self, name: str, payload: dict[str, Any], stream: bool = False
) -> dict[str, Any]:
"""模拟 peer.invoke 方法,用于 CapabilityProxy"""
if name == "platform.send":
await self.send(
payload.get("session", payload.get("session_id", "")),
payload.get("text", ""),
)
return {}
return {}
def create_mock_handler(
handler_id: str = "test.handler",
command: str = "hello",
) -> LoadedHandler:
"""Create a mock loaded handler."""
descriptor = HandlerDescriptor(
id=handler_id,
trigger=CommandTrigger(command=command),
)
async def handler_func(event: MessageEvent, ctx: Context):
await event.reply("Hello!")
return None
handler_func.__func__ = handler_func # Simulate bound method
return LoadedHandler(
descriptor=descriptor,
callable=handler_func,
owner=MagicMock(),
legacy_context=None,
)
def create_invoke_message(
message_id: str = "msg_001",
handler_id: str = "test.handler",
event_data: dict[str, Any] | None = None,
args: dict[str, Any] | None = None,
) -> InvokeMessage:
"""Create a mock invoke message."""
input_data = {"handler_id": handler_id, "event": event_data or {}}
if args:
input_data["args"] = args
return InvokeMessage(
id=message_id,
capability="handler.invoke",
input=input_data,
)
def create_message_event() -> MessageEvent:
"""Create a mock message event."""
return MessageEvent(
session_id="session-1",
user_id="user-1",
platform="test",
text="hello world",
)
class TestHandlerDispatcherInit:
"""Tests for HandlerDispatcher initialization."""
def test_init(self):
"""HandlerDispatcher should initialize with handlers."""
peer = MockPeer()
handler = create_mock_handler()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
assert dispatcher._plugin_id == "test_plugin"
assert dispatcher._peer is peer
assert "test.handler" in dispatcher._handlers
assert dispatcher._active == {}
def test_handlers_indexed_by_id(self):
"""HandlerDispatcher should index handlers by id."""
peer = MockPeer()
handlers = [
create_mock_handler("handler.one", "cmd1"),
create_mock_handler("handler.two", "cmd2"),
]
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=handlers,
)
assert "handler.one" in dispatcher._handlers
assert "handler.two" in dispatcher._handlers
class TestHandlerDispatcherInvoke:
"""Tests for HandlerDispatcher.invoke method."""
@pytest.mark.asyncio
async def test_invoke_calls_handler(self):
"""invoke should call the registered handler."""
peer = MockPeer()
sent_messages = []
# 记录 platform.send 的调用
original_send = peer.send
async def track_send(session_id: str, text: str) -> None:
sent_messages.append({"session_id": session_id, "text": text})
await original_send(session_id, text)
peer.send = track_send
handler_called = []
async def handler_func(e: MessageEvent, ctx: Context):
handler_called.append(e)
await e.reply("response")
descriptor = HandlerDescriptor(
id="test.handler",
trigger=CommandTrigger(command="hello"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=handler_func,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
event = create_message_event()
# MessageEvent 使用 to_payload() 而不是 model_dump()
message = InvokeMessage(
id="msg_001",
capability="handler.invoke",
input={"handler_id": "test.handler", "event": event.to_payload()},
)
cancel_token = CancelToken()
result = await dispatcher.invoke(message, cancel_token)
assert result == {}
assert len(handler_called) == 1
# 验证 reply 通过 platform.send 发送
assert any("response" in m.get("text", "") for m in sent_messages)
@pytest.mark.asyncio
async def test_invoke_missing_handler_raises(self):
"""invoke should raise LookupError for missing handler."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
message = InvokeMessage(
id="msg_001",
capability="handler.invoke",
input={"handler_id": "nonexistent.handler", "event": {}},
)
cancel_token = CancelToken()
with pytest.raises(LookupError, match="handler not found"):
await dispatcher.invoke(message, cancel_token)
@pytest.mark.asyncio
async def test_invoke_with_legacy_args(self):
"""invoke should pass legacy args to handler."""
peer = MockPeer()
received_args = []
async def handler_func(event: MessageEvent, ctx: Context, name: str):
received_args.append(name)
return None
descriptor = HandlerDescriptor(
id="test.handler",
trigger=CommandTrigger(command="hello"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=handler_func,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
message = InvokeMessage(
id="msg_001",
capability="handler.invoke",
input={
"handler_id": "test.handler",
"event": {
"type": "message",
"session_id": "s1",
"user_id": "u1",
"platform": "test",
},
"args": {"name": "test_name"},
},
)
cancel_token = CancelToken()
await dispatcher.invoke(message, cancel_token)
assert "test_name" in received_args
@pytest.mark.asyncio
async def test_invoke_tracks_active_task(self):
"""invoke should track active task."""
peer = MockPeer()
async def slow_handler(event: MessageEvent, ctx: Context):
await asyncio.sleep(0.1)
return None
descriptor = HandlerDescriptor(
id="slow.handler",
trigger=CommandTrigger(command="slow"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=slow_handler,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
message = InvokeMessage(
id="msg_001",
capability="handler.invoke",
input={
"handler_id": "slow.handler",
"event": {
"type": "message",
"session_id": "s1",
"user_id": "u1",
"platform": "test",
},
},
)
cancel_token = CancelToken()
# Start invoke in background
task = asyncio.create_task(dispatcher.invoke(message, cancel_token))
# Give it time to start
await asyncio.sleep(0)
# Should have active task during execution
# Note: might be empty if task completes quickly
await task
# After completion, should be cleared
assert "msg_001" not in dispatcher._active
@pytest.mark.asyncio
async def test_invoke_wraps_legacy_astr_message_event(self):
"""Annotated AstrMessageEvent handlers should receive the compat wrapper."""
peer = MockPeer()
received_types = []
replies = []
async def handler_func(event: AstrMessageEvent):
received_types.append(type(event))
yield event.plain_result("legacy reply")
async def track_send(session_id: str, text: str) -> None:
replies.append({"session_id": session_id, "text": text})
peer.send = track_send
descriptor = HandlerDescriptor(
id="legacy.handler",
trigger=CommandTrigger(command="hello"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=handler_func,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
message = InvokeMessage(
id="msg_legacy",
capability="handler.invoke",
input={
"handler_id": "legacy.handler",
"event": {
"text": "hello",
"session_id": "session-legacy",
"user_id": "user-1",
"platform": "test",
},
},
)
await dispatcher.invoke(message, CancelToken())
assert received_types == [AstrMessageEvent]
assert replies == [{"session_id": "session-legacy", "text": "legacy reply"}]
class TestHandlerDispatcherCancel:
"""Tests for HandlerDispatcher.cancel method."""
@pytest.mark.asyncio
async def test_cancel_stops_active_task(self):
"""cancel should stop the active task."""
peer = MockPeer()
cancelled = []
async def slow_handler(event: MessageEvent, ctx: Context):
try:
await asyncio.sleep(10) # Long sleep
except asyncio.CancelledError:
cancelled.append(True)
raise
descriptor = HandlerDescriptor(
id="slow.handler",
trigger=CommandTrigger(command="slow"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=slow_handler,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
message = InvokeMessage(
id="msg_001",
capability="handler.invoke",
input={
"handler_id": "slow.handler",
"event": {
"type": "message",
"session_id": "s1",
"user_id": "u1",
"platform": "test",
},
},
)
cancel_token = CancelToken()
# Start invoke
task = asyncio.create_task(dispatcher.invoke(message, cancel_token))
# Wait for task to be active
await asyncio.sleep(0.05)
# Cancel
await dispatcher.cancel("msg_001")
# Task should be cancelled
await asyncio.sleep(0.05)
assert cancelled or task.cancelled() or task.done()
@pytest.mark.asyncio
async def test_cancel_unknown_request_does_nothing(self):
"""cancel should do nothing for unknown request."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
# Should not raise
await dispatcher.cancel("unknown_request")
class TestHandlerDispatcherBuildArgs:
"""Tests for HandlerDispatcher._build_args method."""
def test_build_args_event_parameter(self):
"""_build_args should inject event parameter."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
def handler(event: MessageEvent, ctx: Context):
pass
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
args = dispatcher._build_args(handler, event, ctx)
assert args[0] is event
def test_build_args_ctx_parameter(self):
"""_build_args should inject ctx/context parameter."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
def handler(event: MessageEvent, ctx: Context):
pass
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
args = dispatcher._build_args(handler, event, ctx)
assert args[1] is ctx
def test_build_args_legacy_args(self):
"""_build_args should inject legacy args."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
def handler(event: MessageEvent, ctx: Context, custom_arg: str):
pass
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
args = dispatcher._build_args(handler, event, ctx, {"custom_arg": "value"})
assert args[2] == "value"
def test_build_args_skip_keyword_only(self):
"""_build_args should skip keyword-only parameters."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
def handler(event: MessageEvent, *, optional: str = "default"):
pass
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
args = dispatcher._build_args(handler, event, ctx)
# Should only have event
assert len(args) == 1
class TestHandlerDispatcherConsumeResult:
"""Tests for HandlerDispatcher._consume_legacy_result method."""
@pytest.mark.asyncio
async def test_consume_plain_text_result(self):
"""_consume_legacy_result should handle PlainTextResult."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
replies = []
event = create_message_event()
# reply_handler 必须是异步的
async def async_reply(text: str) -> None:
replies.append(text)
event._reply_handler = async_reply
result = PlainTextResult(text="plain text")
await dispatcher._consume_legacy_result(result, event)
assert "plain text" in replies
@pytest.mark.asyncio
async def test_consume_string(self):
"""_consume_legacy_result should handle string."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
replies = []
event = create_message_event()
# reply_handler 必须是异步的
async def async_reply(text: str) -> None:
replies.append(text)
event._reply_handler = async_reply
await dispatcher._consume_legacy_result("string reply", event)
assert "string reply" in replies
@pytest.mark.asyncio
async def test_consume_dict_with_text(self):
"""_consume_legacy_result should handle dict with text."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
replies = []
event = create_message_event()
# reply_handler 必须是异步的
async def async_reply(text: str) -> None:
replies.append(text)
event._reply_handler = async_reply
await dispatcher._consume_legacy_result({"text": "dict reply"}, event)
assert "dict reply" in replies
@pytest.mark.asyncio
async def test_consume_other_type_ignored(self):
"""_consume_legacy_result should ignore other types."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
event = create_message_event()
event._reply_handler = MagicMock()
# Should not raise
await dispatcher._consume_legacy_result(123, event)
await dispatcher._consume_legacy_result(None, event)
class TestHandlerDispatcherHandleError:
"""Tests for HandlerDispatcher._handle_error method."""
@pytest.mark.asyncio
async def test_handle_error_with_on_error_method(self):
"""_handle_error should call owner.on_error if available."""
peer = MockPeer()
errors_handled = []
class OwnerWithOnError:
async def on_error(self, exc: Exception, event: MessageEvent, ctx: Context):
errors_handled.append(exc)
owner = OwnerWithOnError()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
exc = ValueError("test error")
await dispatcher._handle_error(owner, exc, event, ctx)
assert exc in errors_handled
@pytest.mark.asyncio
async def test_handle_error_without_on_error_method(self):
"""_handle_error should use Star.on_error if owner has no on_error."""
peer = MockPeer()
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[],
)
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
exc = ValueError("test error")
# owner 是 MagicMockon_error 方法返回 MagicMock 而不是协程
# 但 _handle_error 会 await owner.on_error(...)
# 所以我们需要让 owner.on_error 返回一个协程
owner = MagicMock()
owner.on_error = AsyncMock()
# Should not raise
await dispatcher._handle_error(owner, exc, event, ctx)
class TestHandlerDispatcherRunHandler:
"""Tests for HandlerDispatcher._run_handler method."""
@pytest.mark.asyncio
async def test_run_handler_sync_function(self):
"""_run_handler should handle sync function."""
peer = MockPeer()
called = []
def sync_handler(event: MessageEvent, ctx: Context):
called.append(True)
descriptor = HandlerDescriptor(
id="sync.handler",
trigger=CommandTrigger(command="sync"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=sync_handler,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
await dispatcher._run_handler(handler, event, ctx)
assert called
@pytest.mark.asyncio
async def test_run_handler_async_function(self):
"""_run_handler should handle async function."""
peer = MockPeer()
called = []
async def async_handler(event: MessageEvent, ctx: Context):
called.append(True)
descriptor = HandlerDescriptor(
id="async.handler",
trigger=CommandTrigger(command="async"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=async_handler,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
await dispatcher._run_handler(handler, event, ctx)
assert called
@pytest.mark.asyncio
async def test_run_handler_async_generator(self):
"""_run_handler should handle async generator."""
peer = MockPeer()
replies = []
async def gen_handler(event: MessageEvent, ctx: Context):
yield "first"
yield "second"
descriptor = HandlerDescriptor(
id="gen.handler",
trigger=CommandTrigger(command="gen"),
)
handler = LoadedHandler(
descriptor=descriptor,
callable=gen_handler,
owner=MagicMock(),
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
event = create_message_event()
# reply_handler 必须是异步的
async def async_reply(text: str) -> None:
replies.append(text)
event._reply_handler = async_reply
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
await dispatcher._run_handler(handler, event, ctx)
assert "first" in replies
assert "second" in replies
@pytest.mark.asyncio
async def test_run_handler_with_exception(self):
"""_run_handler should handle exceptions."""
peer = MockPeer()
async def failing_handler(event: MessageEvent, ctx: Context):
raise ValueError("handler error")
descriptor = HandlerDescriptor(
id="failing.handler",
trigger=CommandTrigger(command="fail"),
)
# owner.on_error 需要是异步的
owner = MagicMock()
owner.on_error = AsyncMock()
handler = LoadedHandler(
descriptor=descriptor,
callable=failing_handler,
owner=owner,
legacy_context=None,
)
dispatcher = HandlerDispatcher(
plugin_id="test_plugin",
peer=peer,
handlers=[handler],
)
event = create_message_event()
ctx = Context(peer=peer, plugin_id="test", cancel_token=CancelToken())
with pytest.raises(ValueError, match="handler error"):
await dispatcher._run_handler(handler, event, ctx)