feat: 添加测试用例,覆盖 API 装饰器、事件过滤器和遗留上下文的功能

This commit is contained in:
whatevertogo
2026-03-13 00:47:31 +08:00
parent 9bf831810d
commit 9d9cd9dd6d
4 changed files with 943 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
"""
Tests for api/event/filter.py - Event filter decorators and utilities.
"""
from __future__ import annotations
import pytest
from astrbot_sdk.api.event.filter import ADMIN, command, filter, permission, regex
from astrbot_sdk.decorators import get_handler_meta
from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger
class TestCommandFilter:
"""Tests for command() filter function."""
def test_command_creates_command_trigger(self):
"""command() should create a CommandTrigger."""
@command("hello")
async def hello_handler():
pass
meta = get_handler_meta(hello_handler)
assert meta is not None
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "hello"
def test_command_is_decorator(self):
"""command() should be usable as a decorator."""
@command("test")
async def test_handler():
pass
assert hasattr(test_handler, "__astrbot_handler_meta__")
class TestRegexFilter:
"""Tests for regex() filter function."""
def test_regex_creates_message_trigger_with_regex(self):
"""regex() should create a MessageTrigger with regex pattern."""
@regex(r"\d+")
async def number_handler():
pass
meta = get_handler_meta(number_handler)
assert meta is not None
assert isinstance(meta.trigger, MessageTrigger)
assert meta.trigger.regex == r"\d+"
def test_regex_pattern_is_stored(self):
"""regex() should store the pattern correctly."""
@regex(r"hello\s+world")
async def greeting_handler():
pass
meta = get_handler_meta(greeting_handler)
assert meta.trigger.regex == r"hello\s+world"
class TestPermissionFilter:
"""Tests for permission() filter function."""
def test_permission_admin_sets_require_admin(self):
"""permission(ADMIN) should set require_admin permission."""
@permission(ADMIN)
async def admin_handler():
pass
meta = get_handler_meta(admin_handler)
assert meta is not None
assert meta.permissions.require_admin is True
def test_permission_non_admin_passes_through(self):
"""permission() with non-ADMIN level should pass through."""
@permission("user")
async def user_handler():
pass
# Should not set admin permission
meta = get_handler_meta(user_handler)
assert meta is None or not meta.permissions.require_admin
def test_permission_can_be_combined_with_command(self):
"""permission() can be combined with other decorators."""
@command("admin")
@permission(ADMIN)
async def admin_command():
pass
meta = get_handler_meta(admin_command)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "admin"
assert meta.permissions.require_admin is True
class TestFilterNamespace:
"""Tests for filter namespace object."""
def test_filter_namespace_has_command(self):
"""filter namespace should have command method."""
assert hasattr(filter, "command")
assert callable(filter.command)
def test_filter_namespace_has_regex(self):
"""filter namespace should have regex method."""
assert hasattr(filter, "regex")
assert callable(filter.regex)
def test_filter_namespace_has_permission(self):
"""filter namespace should have permission method."""
assert hasattr(filter, "permission")
assert callable(filter.permission)
def test_filter_command_works_as_decorator(self):
"""filter.command() should work as a decorator."""
@filter.command("ping")
async def ping_handler():
pass
meta = get_handler_meta(ping_handler)
assert meta.trigger.command == "ping"
def test_filter_regex_works_as_decorator(self):
"""filter.regex() should work as a decorator."""
@filter.regex(r"test")
async def test_handler():
pass
meta = get_handler_meta(test_handler)
assert meta.trigger.regex == r"test"
def test_filter_permission_admin_works(self):
"""filter.permission(ADMIN) should set admin permission."""
@filter.permission(ADMIN)
async def admin_handler():
pass
meta = get_handler_meta(admin_handler)
assert meta.permissions.require_admin is True
class TestAdminConstant:
"""Tests for ADMIN constant."""
def test_admin_constant_value(self):
"""ADMIN constant should be 'admin'."""
assert ADMIN == "admin"
class TestModuleExports:
"""Tests for module exports."""
def test_all_exports(self):
"""Module should export expected names."""
from astrbot_sdk.api.event.filter import __all__
assert "ADMIN" in __all__
assert "command" in __all__
assert "regex" in __all__
assert "permission" in __all__
assert "filter" in __all__

View File

@@ -0,0 +1,364 @@
"""
Tests for _legacy_api.py - Legacy compatibility layer.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot_sdk._legacy_api import (
MIGRATION_DOC_URL,
CommandComponent,
Context,
LegacyContext,
LegacyConversationManager,
)
from astrbot_sdk.star import Star
class TestLegacyContext:
"""Tests for LegacyContext."""
def test_init_with_plugin_id(self):
"""LegacyContext should store plugin_id."""
ctx = LegacyContext("test_plugin")
assert ctx.plugin_id == "test_plugin"
def test_has_conversation_manager(self):
"""LegacyContext should have conversation_manager."""
ctx = LegacyContext("test_plugin")
assert hasattr(ctx, "conversation_manager")
assert isinstance(ctx.conversation_manager, LegacyConversationManager)
def test_require_runtime_context_raises_when_not_bound(self):
"""require_runtime_context() should raise when not bound."""
ctx = LegacyContext("test_plugin")
with pytest.raises(RuntimeError, match="尚未绑定运行时 Context"):
ctx.require_runtime_context()
def test_bind_runtime_context(self):
"""bind_runtime_context() should bind a NewContext."""
mock_ctx = MagicMock()
mock_ctx.plugin_id = "test"
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx.bind_runtime_context(mock_ctx)
assert legacy_ctx.require_runtime_context() is mock_ctx
def test_context_alias_is_legacy_context(self):
"""Context should be an alias for LegacyContext."""
assert Context is LegacyContext
class TestLegacyConversationManager:
"""Tests for LegacyConversationManager."""
def test_init_with_parent(self):
"""LegacyConversationManager should store parent reference."""
parent = LegacyContext("test_plugin")
manager = LegacyConversationManager(parent)
assert manager._parent is parent
@pytest.mark.asyncio
async def test_new_conversation_creates_id(self):
"""new_conversation() should create a conversation ID."""
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = AsyncMock()
mock_ctx.db.get = AsyncMock(return_value=None)
mock_ctx.db.set = AsyncMock()
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
conv_id = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
assert conv_id.startswith("my_plugin-conv-")
assert conv_id.endswith("-1")
@pytest.mark.asyncio
async def test_new_conversation_stores_metadata(self):
"""new_conversation() should store conversation metadata."""
stored_data = {}
async def mock_get(key):
return stored_data.get(key)
async def mock_set(key, value):
stored_data[key] = value
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
mock_ctx.db.set = mock_set
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
conv_id = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1",
platform_id="telegram",
title="Test Chat",
persona_id="assistant",
)
# Verify stored data
assert "__compat_conversations__" in stored_data
assert conv_id in stored_data["__compat_conversations__"]
data = stored_data["__compat_conversations__"][conv_id]
assert data["platform_id"] == "telegram"
assert data["title"] == "Test Chat"
assert data["persona_id"] == "assistant"
@pytest.mark.asyncio
async def test_new_conversation_increments_counter(self):
"""new_conversation() should increment counter per origin."""
stored_data = {}
async def mock_get(key):
return stored_data.get(key)
async def mock_set(key, value):
stored_data[key] = value
mock_ctx = MagicMock()
mock_ctx.plugin_id = "my_plugin"
mock_ctx.db = MagicMock()
mock_ctx.db.get = mock_get
mock_ctx.db.set = mock_set
legacy_ctx = LegacyContext("my_plugin")
legacy_ctx._runtime_context = mock_ctx
id1 = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
id2 = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-1"
)
id3 = await legacy_ctx.conversation_manager.new_conversation(
unified_msg_origin="session-2"
)
assert id1.endswith("-1")
assert id2.endswith("-2")
assert id3.endswith("-1") # Different session, starts at 1
class TestLegacyContextMethods:
"""Tests for LegacyContext methods that delegate to NewContext."""
@pytest.mark.asyncio
async def test_put_kv_data(self):
"""put_kv_data() should delegate to db.set()."""
mock_db = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.put_kv_data("test_key", {"value": 123})
mock_db.set.assert_called_once_with("test_key", {"value": 123})
@pytest.mark.asyncio
async def test_get_kv_data(self):
"""get_kv_data() should delegate to db.get()."""
mock_db = AsyncMock()
mock_db.get = AsyncMock(return_value={"data": "hello"})
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
result = await legacy_ctx.get_kv_data("my_key")
mock_db.get.assert_called_once_with("my_key")
assert result == {"data": "hello"}
@pytest.mark.asyncio
async def test_delete_kv_data(self):
"""delete_kv_data() should delegate to db.delete()."""
mock_db = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.db = mock_db
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.delete_kv_data("to_delete")
mock_db.delete.assert_called_once_with("to_delete")
class TestLegacyContextSendMessage:
"""Tests for LegacyContext.send_message()."""
@pytest.mark.asyncio
async def test_send_message_with_plain_string(self):
"""send_message() should handle plain string."""
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", "hello world")
mock_platform.send.assert_called_once_with("session-1", "hello world")
@pytest.mark.asyncio
async def test_send_message_with_get_plain_text_method(self):
"""send_message() should use get_plain_text() if available."""
class MockMessageChain:
def get_plain_text(self):
return "extracted text"
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", MockMessageChain())
mock_platform.send.assert_called_once_with("session-1", "extracted text")
@pytest.mark.asyncio
async def test_send_message_with_to_text_method(self):
"""send_message() should use to_text() if get_plain_text() not available."""
class MockMessageChain:
def to_text(self):
return "to_text result"
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", MockMessageChain())
mock_platform.send.assert_called_once_with("session-1", "to_text result")
@pytest.mark.asyncio
async def test_send_message_falls_back_to_str(self):
"""send_message() should fall back to str() if no text method."""
class MockObject:
def __str__(self):
return "stringified"
mock_platform = AsyncMock()
mock_ctx = MagicMock()
mock_ctx.platform = mock_platform
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
await legacy_ctx.send_message("session-1", MockObject())
mock_platform.send.assert_called_once_with("session-1", "stringified")
class TestLegacyContextLLMMethods:
"""Tests for LegacyContext LLM methods."""
@pytest.mark.asyncio
async def test_llm_generate_delegates_to_chat_raw(self):
"""llm_generate() should delegate to ctx.llm.chat_raw()."""
mock_llm = AsyncMock()
mock_llm.chat_raw = AsyncMock(return_value=MagicMock(text="response"))
mock_ctx = MagicMock()
mock_ctx.llm = mock_llm
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
result = await legacy_ctx.llm_generate(
chat_provider_id="provider-1",
prompt="hello",
system_prompt="be helpful",
contexts=[{"role": "user", "content": "hi"}],
)
mock_llm.chat_raw.assert_called_once()
assert result is not None
@pytest.mark.asyncio
async def test_tool_loop_agent_delegates_to_chat_raw(self):
"""tool_loop_agent() should delegate to ctx.llm.chat_raw()."""
mock_llm = AsyncMock()
mock_llm.chat_raw = AsyncMock(return_value=MagicMock(text="response"))
mock_ctx = MagicMock()
mock_ctx.llm = mock_llm
legacy_ctx = LegacyContext("test_plugin")
legacy_ctx._runtime_context = mock_ctx
result = await legacy_ctx.tool_loop_agent(
chat_provider_id="provider-1",
prompt="hello",
max_steps=10,
)
mock_llm.chat_raw.assert_called_once()
call_kwargs = mock_llm.chat_raw.call_args[1]
assert call_kwargs["max_steps"] == 10
@pytest.mark.asyncio
async def test_add_llm_tools_returns_none(self):
"""add_llm_tools() should return None (deprecated)."""
legacy_ctx = LegacyContext("test_plugin")
result = await legacy_ctx.add_llm_tools("tool1", "tool2")
assert result is None
class TestCommandComponent:
"""Tests for CommandComponent."""
def test_is_star_subclass(self):
"""CommandComponent should be a Star subclass."""
assert issubclass(CommandComponent, Star)
def test_is_not_new_star(self):
"""CommandComponent should NOT be recognized as new-style star."""
assert CommandComponent.__astrbot_is_new_star__() is False
def test_create_legacy_context(self):
"""_astrbot_create_legacy_context() should create LegacyContext."""
ctx = CommandComponent._astrbot_create_legacy_context("my_plugin")
assert isinstance(ctx, LegacyContext)
assert ctx.plugin_id == "my_plugin"
class TestMigrationDocUrl:
"""Tests for migration documentation URL."""
def test_migration_doc_url_exists(self):
"""MIGRATION_DOC_URL should be defined."""
assert MIGRATION_DOC_URL is not None
assert "docs.astrbot.app" in MIGRATION_DOC_URL

View File

@@ -0,0 +1,77 @@
"""
Tests for API module exports and re-exports.
"""
from __future__ import annotations
import pytest
class TestApiStarModule:
"""Tests for api/star module exports."""
def test_star_module_exports_context(self):
"""api.star should export Context."""
from astrbot_sdk.api.star import Context
assert Context is not None
def test_star_context_is_legacy_context(self):
"""api.star.Context should be LegacyContext."""
from astrbot_sdk._legacy_api import LegacyContext
from astrbot_sdk.api.star import Context
assert Context is LegacyContext
class TestApiComponentsModule:
"""Tests for api/components module exports."""
def test_components_module_exports_command_component(self):
"""api.components should export CommandComponent."""
from astrbot_sdk.api.components import CommandComponent
assert CommandComponent is not None
def test_command_component_is_from_legacy_api(self):
"""api.components.CommandComponent should be from _legacy_api."""
from astrbot_sdk._legacy_api import CommandComponent as LegacyCommandComponent
from astrbot_sdk.api.components import CommandComponent
assert CommandComponent is LegacyCommandComponent
class TestApiEventModule:
"""Tests for api/event module exports."""
def test_event_module_exports(self):
"""api.event should export expected names."""
from astrbot_sdk.api.event import ADMIN, AstrMessageEvent, filter
assert ADMIN == "admin"
assert filter is not None
assert AstrMessageEvent is not None
def test_astr_message_event_is_message_event(self):
"""AstrMessageEvent should be MessageEvent."""
from astrbot_sdk.api.event import AstrMessageEvent
from astrbot_sdk.events import MessageEvent
assert AstrMessageEvent is MessageEvent
def test_all_exports(self):
"""api.event should export all expected names."""
from astrbot_sdk.api.event import __all__
assert "ADMIN" in __all__
assert "AstrMessageEvent" in __all__
assert "filter" in __all__
class TestApiModule:
"""Tests for top-level api module."""
def test_api_module_exists(self):
"""api module should be importable."""
import astrbot_sdk.api
assert astrbot_sdk.api is not None

331
tests_v4/test_decorators.py Normal file
View File

@@ -0,0 +1,331 @@
"""
Tests for decorators.py - Handler decorator infrastructure.
"""
from __future__ import annotations
import pytest
from astrbot_sdk.decorators import (
HANDLER_META_ATTR,
HandlerMeta,
get_handler_meta,
on_command,
on_event,
on_message,
on_schedule,
require_admin,
)
from astrbot_sdk.protocol.descriptors import (
CommandTrigger,
EventTrigger,
MessageTrigger,
Permissions,
ScheduleTrigger,
)
class TestHandlerMeta:
"""Tests for HandlerMeta dataclass."""
def test_default_values(self):
"""HandlerMeta should have default values."""
meta = HandlerMeta()
assert meta.trigger is None
assert meta.priority == 0
assert isinstance(meta.permissions, Permissions)
def test_trigger_assignment(self):
"""HandlerMeta should accept trigger assignment."""
trigger = CommandTrigger(command="test")
meta = HandlerMeta(trigger=trigger)
assert meta.trigger is trigger
def test_priority_assignment(self):
"""HandlerMeta should accept priority assignment."""
meta = HandlerMeta(priority=10)
assert meta.priority == 10
def test_permissions_assignment(self):
"""HandlerMeta should accept permissions assignment."""
permissions = Permissions(require_admin=True)
meta = HandlerMeta(permissions=permissions)
assert meta.permissions.require_admin is True
class TestGetHandlerMeta:
"""Tests for get_handler_meta function."""
def test_returns_none_for_undecorated_function(self):
"""get_handler_meta should return None for undecorated functions."""
async def plain_function():
pass
assert get_handler_meta(plain_function) is None
def test_returns_meta_for_decorated_function(self):
"""get_handler_meta should return meta for decorated functions."""
@on_command("test")
async def decorated():
pass
meta = get_handler_meta(decorated)
assert meta is not None
assert isinstance(meta, HandlerMeta)
class TestOnCommandDecorator:
"""Tests for @on_command decorator."""
def test_sets_handler_meta_attribute(self):
"""@on_command should set __astrbot_handler_meta__ attribute."""
@on_command("hello")
async def handler():
pass
assert hasattr(handler, HANDLER_META_ATTR)
def test_creates_command_trigger(self):
"""@on_command should create CommandTrigger."""
@on_command("hello")
async def handler():
pass
meta = get_handler_meta(handler)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "hello"
def test_supports_aliases(self):
"""@on_command should store aliases."""
@on_command("hello", aliases=["hi", "hey"])
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.aliases == ["hi", "hey"]
def test_supports_description(self):
"""@on_command should store description."""
@on_command("hello", description="Say hello")
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.description == "Say hello"
def test_default_empty_aliases(self):
"""@on_command should default to empty aliases."""
@on_command("test")
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.aliases == []
def test_preserves_function(self):
"""@on_command should preserve the original function."""
@on_command("test")
async def handler():
return "result"
assert handler.__name__ == "handler"
class TestOnMessageDecorator:
"""Tests for @on_message decorator."""
def test_creates_message_trigger(self):
"""@on_message should create MessageTrigger."""
@on_message()
async def handler():
pass
meta = get_handler_meta(handler)
assert isinstance(meta.trigger, MessageTrigger)
def test_supports_regex(self):
"""@on_message should store regex pattern."""
@on_message(regex=r"\d+")
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.regex == r"\d+"
def test_supports_keywords(self):
"""@on_message should store keywords."""
@on_message(keywords=["hello", "hi"])
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.keywords == ["hello", "hi"]
def test_supports_platforms(self):
"""@on_message should store platforms."""
@on_message(platforms=["telegram", "discord"])
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.platforms == ["telegram", "discord"]
def test_defaults_empty_lists(self):
"""@on_message should default to empty lists."""
@on_message()
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.keywords == []
assert meta.trigger.platforms == []
class TestOnEventDecorator:
"""Tests for @on_event decorator."""
def test_creates_event_trigger(self):
"""@on_event should create EventTrigger."""
@on_event("message_received")
async def handler():
pass
meta = get_handler_meta(handler)
assert isinstance(meta.trigger, EventTrigger)
assert meta.trigger.event_type == "message_received"
def test_various_event_types(self):
"""@on_event should handle various event types."""
event_types = ["message_received", "user_joined", "custom_event"]
for event_type in event_types:
@on_event(event_type)
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.event_type == event_type
class TestOnScheduleDecorator:
"""Tests for @on_schedule decorator."""
def test_creates_cron_trigger(self):
"""@on_schedule with cron should create ScheduleTrigger."""
@on_schedule(cron="* * * * *")
async def handler():
pass
meta = get_handler_meta(handler)
assert isinstance(meta.trigger, ScheduleTrigger)
assert meta.trigger.cron == "* * * * *"
def test_creates_interval_trigger(self):
"""@on_schedule with interval should create ScheduleTrigger."""
@on_schedule(interval_seconds=60)
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.trigger.interval_seconds == 60
class TestRequireAdminDecorator:
"""Tests for @require_admin decorator."""
def test_sets_require_admin_permission(self):
"""@require_admin should set require_admin in permissions."""
@require_admin
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.permissions.require_admin is True
def test_can_combine_with_other_decorators(self):
"""@require_admin can be combined with other decorators."""
@on_command("admin")
@require_admin
async def handler():
pass
meta = get_handler_meta(handler)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.trigger.command == "admin"
assert meta.permissions.require_admin is True
def test_order_does_not_matter_for_permissions(self):
"""Decorator order should not affect permissions."""
@require_admin
@on_command("admin")
async def handler1():
pass
@on_command("admin")
@require_admin
async def handler2():
pass
meta1 = get_handler_meta(handler1)
meta2 = get_handler_meta(handler2)
assert meta1.permissions.require_admin is True
assert meta2.permissions.require_admin is True
class TestDecoratorChaining:
"""Tests for chaining multiple decorators."""
def test_multiple_decorators_share_meta(self):
"""Multiple decorators should share the same meta object."""
@on_command("test")
@require_admin
async def handler():
pass
meta = get_handler_meta(handler)
assert isinstance(meta.trigger, CommandTrigger)
assert meta.permissions.require_admin is True
def test_last_trigger_wins(self):
"""Last trigger decorator should override previous ones."""
# Decorators are applied bottom-up, so on_message wins
@on_message()
@on_command("override")
async def handler():
pass
meta = get_handler_meta(handler)
# on_message is applied last (outermost), so it wins
assert isinstance(meta.trigger, MessageTrigger)
def test_permissions_accumulate(self):
"""Permissions should accumulate across decorators."""
@require_admin
@on_command("test")
async def handler():
pass
meta = get_handler_meta(handler)
assert meta.permissions.require_admin is True