refactor(protocols): update protocol client implementations

This commit is contained in:
LIghtJUNction
2026-03-25 00:10:29 +08:00
parent 613910f592
commit be65022de1
17 changed files with 2148 additions and 246 deletions

View File

@@ -0,0 +1,802 @@
"""Tests for astrbot._internal.tools.base module."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from astrbot._internal.tools.base import (
FunctionTool,
ToolSchema,
ToolSet,
)
# =============================================================================
# ToolSchema Tests
# =============================================================================
class TestToolSchema:
"""Test suite for ToolSchema."""
def test_valid_parameters_schema(self):
"""Valid JSON Schema parameters should pass validation."""
schema = ToolSchema(
name="test_tool",
description="A test tool",
parameters={
"type": "object",
"properties": {"arg": {"type": "string", "description": "An argument"}},
"required": ["arg"],
},
)
assert schema.name == "test_tool"
assert schema.description == "A test tool"
assert schema.parameters["type"] == "object"
def test_empty_parameters(self):
"""Empty parameters dict should be valid."""
schema = ToolSchema(name="test", description="test", parameters={})
assert schema.parameters == {}
def test_invalid_parameters_no_op(self):
"""NOTE: ToolSchema is a plain @dataclass, not a Pydantic BaseModel.
The @model_validator decorator has no effect, so validation is dead code.
This test documents the current (broken) behavior for coverage.
"""
# This creates successfully because model_validator is a no-op on plain dataclass
schema = ToolSchema(
name="test",
description="test",
parameters={"type": "invalid_type_not_real"},
)
assert schema.parameters == {"type": "invalid_type_not_real"}
"""Parameters without type field should still be valid since jsonschema validates structure."""
# Actually this should be valid - jsonschema validates the schema itself
schema = ToolSchema(
name="test",
description="test",
parameters={"type": "string"},
)
assert schema.parameters["type"] == "string"
# =============================================================================
# FunctionTool Tests
# =============================================================================
class TestFunctionTool:
"""Test suite for FunctionTool."""
def test_basic_function_tool(self):
"""Basic tool creation with name, description, parameters."""
tool = FunctionTool(
name="my_tool",
description="Does something useful",
parameters={"type": "object", "properties": {}},
)
assert tool.name == "my_tool"
assert tool.description == "Does something useful"
assert tool.active is True
assert tool.is_background_task is False
assert tool.source == "mcp"
def test_function_tool_with_handler(self):
"""Tool with an async handler."""
handler = AsyncMock(return_value="result")
async def async_gen(**kwargs):
yield "chunk1"
yield "chunk2"
tool = FunctionTool(
name="handler_tool",
description="Tool with handler",
parameters={},
handler=handler,
)
assert tool.handler is handler
def test_function_tool_with_handler_module_path(self):
"""Tool preserves handler_module_path."""
tool = FunctionTool(
name="path_tool",
description="Tool with module path",
parameters={},
handler_module_path="mymodule.myfunction",
)
assert tool.handler_module_path == "mymodule.myfunction"
def test_function_tool_active_flag(self):
"""Active flag can be set to False."""
tool = FunctionTool(
name="inactive",
description="Not active",
parameters={},
active=False,
)
assert tool.active is False
def test_function_tool_background_task_flag(self):
"""Background task flag can be set."""
tool = FunctionTool(
name="background",
description="Background task",
parameters={},
is_background_task=True,
)
assert tool.is_background_task is True
def test_function_tool_source_defaults_to_mcp(self):
"""Source defaults to 'mcp'."""
tool = FunctionTool(name="t", description="t", parameters={})
assert tool.source == "mcp"
def test_function_tool_source_can_be_plugin_or_internal(self):
"""Source can be 'plugin' or 'internal'."""
plugin_tool = FunctionTool(
name="p", description="p", parameters={}, source="plugin"
)
internal_tool = FunctionTool(
name="i", description="i", parameters={}, source="internal"
)
assert plugin_tool.source == "plugin"
assert internal_tool.source == "internal"
def test_function_tool_repr(self):
"""__repr__ returns correct string."""
tool = FunctionTool(
name="repr_tool",
description="For repr test",
parameters={"type": "object"},
)
r = repr(tool)
assert "repr_tool" in r
assert "parameters" in r
assert "repr test" in r
@pytest.mark.asyncio
async def test_call_raises_not_implemented(self):
"""call() without handler raises NotImplementedError."""
tool = FunctionTool(name="t", description="t", parameters={})
with pytest.raises(NotImplementedError, match="must be implemented"):
await tool.call(arg="value")
# =============================================================================
# ToolSet Tests
# =============================================================================
class TestToolSetConstruction:
"""Test ToolSet construction and basic operations."""
def test_empty_toolset(self):
"""Empty ToolSet with namespace."""
ts = ToolSet("my_namespace")
assert ts.namespace == "my_namespace"
assert len(ts) == 0
assert ts.empty()
def test_toolset_from_list(self):
"""ToolSet initialized with a list of tools."""
tool1 = FunctionTool(name="tool1", description="First", parameters={})
tool2 = FunctionTool(name="tool2", description="Second", parameters={})
ts = ToolSet("ns", [tool1, tool2])
assert len(ts) == 2
assert not ts.empty()
def test_toolset_with_duplicate_names(self):
"""Last tool with same name overwrites earlier one."""
tool1 = FunctionTool(name="dup", description="First", parameters={})
tool2 = FunctionTool(name="dup", description="Second", parameters={})
ts = ToolSet("ns", [tool1, tool2])
assert len(ts) == 1
assert ts.get("dup").description == "Second"
class TestToolSetAddRemove:
"""Test ToolSet add/remove operations."""
def test_add_tool(self):
"""add() puts tool in set."""
ts = ToolSet("ns")
tool = FunctionTool(name="add_test", description="Add test", parameters={})
ts.add(tool)
assert ts.get("add_test") is tool
def test_add_tool_alias(self):
"""add_tool() is alias for add()."""
ts = ToolSet("ns")
tool = FunctionTool(name="alias_test", description="Alias test", parameters={})
ts.add_tool(tool)
assert ts.get("alias_test") is tool
def test_remove_tool(self):
"""remove_tool() removes by name (void return)."""
ts = ToolSet("ns")
tool = FunctionTool(name="remove_me", description="Remove me", parameters={})
ts.add(tool)
ts.remove_tool("remove_me")
assert ts.get("remove_me") is None
def test_remove_method(self):
"""remove() removes and returns tool."""
ts = ToolSet("ns")
tool = FunctionTool(name="return_me", description="Return me", parameters={})
ts.add(tool)
result = ts.remove("return_me")
assert result is tool
assert ts.get("return_me") is None
def test_remove_nonexistent(self):
"""remove() returns None for missing name."""
ts = ToolSet("ns")
result = ts.remove("does_not_exist")
assert result is None
def test_get_tool_alias(self):
"""get_tool() is alias for get()."""
ts = ToolSet("ns")
tool = FunctionTool(name="get_alias", description="Get alias", parameters={})
ts.add(tool)
assert ts.get_tool("get_alias") is tool
class TestToolSetIteration:
"""Test ToolSet iteration and length."""
def test_len(self):
"""__len__ returns count."""
ts = ToolSet("ns")
assert len(ts) == 0
ts.add(FunctionTool(name="a", description="a", parameters={}))
assert len(ts) == 1
ts.add(FunctionTool(name="b", description="b", parameters={}))
assert len(ts) == 2
def test_bool_true_when_has_tools(self):
"""__bool__ is True when tools exist."""
ts = ToolSet("ns")
assert not ts
ts.add(FunctionTool(name="x", description="x", parameters={}))
assert ts
def test_iter(self):
"""__iter__ yields tools."""
tool1 = FunctionTool(name="iter1", description="Iter 1", parameters={})
tool2 = FunctionTool(name="iter2", description="Iter 2", parameters={})
ts = ToolSet("ns", [tool1, tool2])
tools = list(ts)
assert tool1 in tools
assert tool2 in tools
def test_list_tools(self):
"""list_tools() returns all tools."""
tool1 = FunctionTool(name="list1", description="List 1", parameters={})
tool2 = FunctionTool(name="list2", description="List 2", parameters={})
ts = ToolSet("ns", [tool1, tool2])
assert len(ts.list_tools()) == 2
def test_tools_property(self):
"""tools property returns list of tools."""
tool = FunctionTool(name="prop", description="Prop", parameters={})
ts = ToolSet("ns", [tool])
assert tool in ts.tools
def test_names(self):
"""names() returns list of tool names."""
tool1 = FunctionTool(name="alpha", description="Alpha", parameters={})
tool2 = FunctionTool(name="beta", description="Beta", parameters={})
ts = ToolSet("ns", [tool1, tool2])
assert set(ts.names()) == {"alpha", "beta"}
def test_empty_method(self):
"""empty() returns True when no tools."""
ts = ToolSet("ns")
assert ts.empty()
ts.add(FunctionTool(name="y", description="y", parameters={}))
assert not ts.empty()
class TestToolSetRepr:
"""Test ToolSet string representations."""
def test_repr(self):
"""__repr__ includes namespace and tools."""
tool = FunctionTool(name="repr_t", description="R", parameters={})
ts = ToolSet("repr_ns", [tool])
r = repr(ts)
assert "repr_ns" in r
assert "repr_t" in r
def test_str(self):
"""__str__ includes namespace and count."""
ts = ToolSet("str_ns")
assert "str_ns" in str(ts)
assert "0 tools" in str(ts)
ts.add(FunctionTool(name="s", description="s", parameters={}))
assert "1 tools" in str(ts)
class TestToolSetMerge:
"""Test ToolSet merge and normalize."""
def test_merge(self):
"""merge() adds all tools from another ToolSet."""
ts1 = ToolSet("ns1")
ts1.add(FunctionTool(name="keep", description="Keep", parameters={}))
ts2 = ToolSet("ns2")
ts2.add(FunctionTool(name="added", description="Added", parameters={}))
ts1.merge(ts2)
assert ts1.get("keep") is not None
assert ts1.get("added") is not None
assert len(ts1) == 2
def test_merge_overwrites_duplicate(self):
"""merge() overwrites tools with same name."""
ts1 = ToolSet("ns1")
ts1.add(FunctionTool(name="dup", description="Original", parameters={}))
ts2 = ToolSet("ns2")
ts2.add(FunctionTool(name="dup", description="Merged", parameters={}))
ts1.merge(ts2)
assert ts1.get("dup").description == "Merged"
def test_normalize_sorts_by_name(self):
"""normalize() sorts tools by name for deterministic output."""
tool_c = FunctionTool(name="charlie", description="C", parameters={})
tool_a = FunctionTool(name="alpha", description="A", parameters={})
tool_b = FunctionTool(name="bravo", description="B", parameters={})
ts = ToolSet("ns", [tool_c, tool_a, tool_b])
ts.normalize()
names = list(ts._tools.keys())
assert names == ["alpha", "bravo", "charlie"]
class TestToolSetLightToolSet:
"""Test get_light_tool_set()."""
def test_light_tool_set_excludes_inactive(self):
"""Inactive tools are excluded."""
active = FunctionTool(
name="active", description="Active", parameters={}, active=True
)
inactive = FunctionTool(
name="inactive", description="Inactive", parameters={}, active=False
)
ts = ToolSet("ns", [active, inactive])
light = ts.get_light_tool_set()
assert light.get("active") is not None
assert light.get("inactive") is None
def test_light_tool_set_preserves_name_and_description(self):
"""Light tool set has name/description only."""
tool = FunctionTool(
name="light_test",
description="Original description",
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
)
ts = ToolSet("ns", [tool])
light = ts.get_light_tool_set()
light_tool = light.get("light_test")
assert light_tool.name == "light_test"
assert light_tool.description == "Original description"
assert light_tool.parameters == {"type": "object", "properties": {}}
def test_light_tool_set_has_empty_handler(self):
"""Light tools have handler=None."""
tool = FunctionTool(name="lh", description="LH", parameters={})
ts = ToolSet("ns", [tool])
light = ts.get_light_tool_set()
assert light.get("lh").handler is None
class TestToolSetParamOnlyToolSet:
"""Test get_param_only_tool_set()."""
def test_param_only_excludes_inactive(self):
"""Inactive tools are excluded."""
active = FunctionTool(name="a", description="A", parameters={}, active=True)
inactive = FunctionTool(name="i", description="I", parameters={}, active=False)
ts = ToolSet("ns", [active, inactive])
param = ts.get_param_only_tool_set()
assert param.get("a") is not None
assert param.get("i") is None
def test_param_only_preserves_parameters(self):
"""Parameters are deep copied."""
tool = FunctionTool(
name="param_test",
description="Keep this",
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
)
ts = ToolSet("ns", [tool])
param = ts.get_param_only_tool_set()
param_tool = param.get("param_test")
assert param_tool.parameters == {
"type": "object",
"properties": {"x": {"type": "integer"}},
}
assert param_tool.description == ""
def test_param_only_empty_parameters_defaults(self):
"""Tools with no parameters get empty object schema."""
tool = FunctionTool(name="no_params", description="No params", parameters=None)
ts = ToolSet("ns", [tool])
param = ts.get_param_only_tool_set()
assert param.get("no_params").parameters == {"type": "object", "properties": {}}
# =============================================================================
# ToolSet Schema Tests - OpenAI
# =============================================================================
class TestToolSetOpenAISchema:
"""Test openai_schema()."""
def test_empty_toolset(self):
"""Empty toolset returns empty list."""
ts = ToolSet("ns")
assert ts.openai_schema() == []
def test_basic_openai_schema(self):
"""Basic tool converts to OpenAI format."""
tool = FunctionTool(
name="openai_tool",
description="An OpenAI tool",
parameters={"type": "object", "properties": {}},
)
ts = ToolSet("ns", [tool])
schema = ts.openai_schema()
assert len(schema) == 1
assert schema[0]["type"] == "function"
assert schema[0]["function"]["name"] == "openai_tool"
assert schema[0]["function"]["description"] == "An OpenAI tool"
assert "parameters" in schema[0]["function"]
def test_openai_schema_no_description(self):
"""Tool without description omits description field."""
tool = FunctionTool(name="nodesc", description="", parameters={})
ts = ToolSet("ns", [tool])
schema = ts.openai_schema()
assert "description" not in schema[0]["function"]
def test_openai_schema_omit_empty_parameters_true(self):
"""omit_empty_parameter_field=True removes empty parameters."""
tool = FunctionTool(
name="omit_empty",
description="Test",
parameters={"type": "object", "properties": {}},
)
ts = ToolSet("ns", [tool])
schema = ts.openai_schema(omit_empty_parameter_field=True)
assert "parameters" not in schema[0]["function"]
def test_openai_schema_omit_empty_with_properties(self):
"""omit_empty=True but has properties -> keeps parameters."""
tool = FunctionTool(
name="keep_params",
description="Test",
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
)
ts = ToolSet("ns", [tool])
schema = ts.openai_schema(omit_empty_parameter_field=True)
assert "parameters" in schema[0]["function"]
def test_openai_schema_null_parameters(self):
"""Tool with parameters=None skips parameters field."""
tool = FunctionTool(name="null_params", description="Test", parameters=None)
ts = ToolSet("ns", [tool])
schema = ts.openai_schema()
# Since parameters is None, tool.parameters is None, so the condition
# tool.parameters is not None is False, and omit_empty is False by default
# so parameters should not be in the output
assert "parameters" not in schema[0]["function"]
# =============================================================================
# ToolSet Schema Tests - Anthropic
# =============================================================================
class TestToolSetAnthropicSchema:
"""Test anthropic_schema()."""
def test_empty_toolset(self):
"""Empty toolset returns empty list."""
ts = ToolSet("ns")
assert ts.anthropic_schema() == []
def test_basic_anthropic_schema(self):
"""Basic tool converts to Anthropic format."""
tool = FunctionTool(
name="anthropic_tool",
description="An Anthropic tool",
parameters={
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
)
ts = ToolSet("ns", [tool])
schema = ts.anthropic_schema()
assert len(schema) == 1
assert schema[0]["name"] == "anthropic_tool"
assert schema[0]["description"] == "An Anthropic tool"
assert schema[0]["input_schema"]["properties"] == {"query": {"type": "string"}}
assert schema[0]["input_schema"]["required"] == ["query"]
def test_anthropic_schema_no_parameters(self):
"""Tool with no parameters gets empty input_schema."""
tool = FunctionTool(name="no_params", description="No params", parameters={})
ts = ToolSet("ns", [tool])
schema = ts.anthropic_schema()
assert schema[0]["input_schema"] == {"type": "object"}
def test_anthropic_schema_no_description(self):
"""Tool without description omits description field."""
tool = FunctionTool(name="nodesc", description="", parameters={})
ts = ToolSet("ns", [tool])
schema = ts.anthropic_schema()
assert "description" not in schema[0]
# =============================================================================
# ToolSet Schema Tests - Google GenAI
# =============================================================================
class TestToolSetGoogleSchema:
"""Test google_schema()."""
def test_empty_toolset(self):
"""Empty toolset returns empty declarations."""
ts = ToolSet("ns")
assert ts.google_schema() == {}
def test_basic_google_schema(self):
"""Basic tool converts to Google format."""
tool = FunctionTool(
name="google_tool",
description="A Google tool",
parameters={"type": "object", "properties": {}},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
assert "function_declarations" in schema
assert len(schema["function_declarations"]) == 1
decl = schema["function_declarations"][0]
assert decl["name"] == "google_tool"
assert decl["description"] == "A Google tool"
def test_google_convert_any_of(self):
"""anyOf schemas are recursively converted."""
tool = FunctionTool(
name="anyof_tool",
description="AnyOf test",
parameters={
"type": "object",
"properties": {
"value": {
"anyOf": [
{"type": "string"},
{"type": "integer"},
]
}
},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert "anyOf" in props["value"]
assert len(props["value"]["anyOf"]) == 2
def test_google_convert_array_with_items(self):
"""Array types with items dict are converted."""
tool = FunctionTool(
name="array_tool",
description="Array test",
parameters={
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
}
},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert props["tags"]["type"] == "array"
assert props["tags"]["items"] == {"type": "string"}
def test_google_convert_array_with_non_dict_items(self):
"""Array types with non-dict items default to string."""
tool = FunctionTool(
name="array_tool2",
description="Array test 2",
parameters={
"type": "object",
"properties": {"items": {"type": "array", "items": "not_a_dict"}},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert props["items"]["items"] == {"type": "string"}
def test_google_unsupported_type_becomes_null(self):
"""Unsupported types become 'null'."""
tool = FunctionTool(
name="unsupported",
description="Unsupported type",
parameters={
"type": "object",
"properties": {"unknown": {"type": "unsupported_type_xyz"}},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert props["unknown"]["type"] == "null"
def test_google_type_list_picks_non_null(self):
"""Type list like ['string', 'null'] picks 'string'."""
tool = FunctionTool(
name="nullable_str",
description="Nullable string",
parameters={
"type": "object",
"properties": {"name": {"type": ["string", "null"]}},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert props["name"]["type"] == "string"
def test_google_removes_default_and_additional_props(self):
"""default and additionalProperties are removed during conversion.
These fields survive convert_schema via support_fields (e.g. via 'description').
"""
tool = FunctionTool(
name="cleanup",
description="Cleanup test",
parameters={
"type": "object",
"properties": {
"field": {
"type": "string",
"description": "A field with default",
"default": "foo",
},
},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
field = schema["function_declarations"][0]["parameters"]["properties"]["field"]
# description should be preserved, default should be removed
assert field.get("description") == "A field with default"
assert "default" not in field
def test_google_supported_fields_preserved(self):
"""Supported fields like enum, minimum, maximum are preserved."""
tool = FunctionTool(
name="fields",
description="Fields test",
parameters={
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "inactive"],
"description": "Status field",
},
"count": {
"type": "integer",
"minimum": 0,
"maximum": 100,
},
"items": {
"type": "array",
"maxItems": 10,
"minItems": 1,
},
},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert props["status"]["enum"] == ["active", "inactive"]
assert props["status"]["description"] == "Status field"
assert props["count"]["minimum"] == 0
assert props["count"]["maximum"] == 100
assert props["items"]["maxItems"] == 10
assert props["items"]["minItems"] == 1
def test_google_format_fields(self):
"""Format fields are preserved for supported types."""
tool = FunctionTool(
name="format_test",
description="Format test",
parameters={
"type": "object",
"properties": {
"dt": {"type": "string", "format": "date-time"},
"enum_val": {"type": "string", "format": "enum"},
"int32": {"type": "integer", "format": "int32"},
"int64": {"type": "integer", "format": "int64"},
"float_val": {"type": "number", "format": "float"},
"double_val": {"type": "number", "format": "double"},
},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert props["dt"]["format"] == "date-time"
assert props["int32"]["format"] == "int32"
assert props["int64"]["format"] == "int64"
assert props["float_val"]["format"] == "float"
assert props["double_val"]["format"] == "double"
def test_google_unsupported_format_ignored(self):
"""Format not in supported list is ignored."""
tool = FunctionTool(
name="bad_format",
description="Bad format",
parameters={
"type": "object",
"properties": {
"bad": {"type": "string", "format": "unsupported-format-xyz"}
},
},
)
ts = ToolSet("ns", [tool])
schema = ts.google_schema()
props = schema["function_declarations"][0]["parameters"]["properties"]
assert "format" not in props["bad"]
class TestToolSetDeprecatedSchemaMethods:
"""Test deprecated schema convenience methods."""
def test_get_func_desc_openai_style(self):
"""get_func_desc_openai_style returns same as openai_schema."""
tool = FunctionTool(name="dep_openai", description="Deprecated", parameters={})
ts = ToolSet("ns", [tool])
assert ts.get_func_desc_openai_style() == ts.openai_schema()
def test_get_func_desc_openai_style_with_flag(self):
"""get_func_desc_openai_style passes omit_empty flag."""
tool = FunctionTool(
name="dep_omit",
description="Omit",
parameters={"type": "object", "properties": {}},
)
ts = ToolSet("ns", [tool])
assert ts.get_func_desc_openai_style(
omit_empty_parameter_field=True
) == ts.openai_schema(omit_empty_parameter_field=True)
def test_get_func_desc_anthropic_style(self):
"""get_func_desc_anthropic_style returns same as anthropic_schema."""
tool = FunctionTool(
name="dep_anthropic", description="Anthropic", parameters={}
)
ts = ToolSet("ns", [tool])
assert ts.get_func_desc_anthropic_style() == ts.anthropic_schema()
def test_get_func_desc_google_genai_style(self):
"""get_func_desc_google_genai_style returns same as google_schema."""
tool = FunctionTool(name="dep_google", description="Google", parameters={})
ts = ToolSet("ns", [tool])
assert ts.get_func_desc_google_genai_style() == ts.google_schema()

View File

@@ -0,0 +1,399 @@
"""Tests for StarHandlerRegistry and StarHandlerMetadata."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from astrbot.core.star.star_handler import (
EventType,
StarHandlerMetadata,
StarHandlerRegistry,
)
@pytest.fixture
def registry():
"""Create a fresh StarHandlerRegistry."""
return StarHandlerRegistry()
@pytest.fixture
def mock_handler():
"""Create a mock handler for testing."""
def make_handler(
event_type: EventType,
full_name: str,
module_path: str = "test_module",
enabled: bool = True,
priority: int = 0,
extras_configs: dict | None = None,
) -> StarHandlerMetadata:
handler = MagicMock(spec=StarHandlerMetadata)
handler.event_type = event_type
handler.handler_full_name = full_name
handler.handler_name = full_name.split("_")[-1]
handler.handler_module_path = module_path
handler.enabled = enabled
configs = extras_configs or {}
if priority != 0:
configs["priority"] = priority
handler.extras_configs = configs
return handler
return make_handler
class TestStarHandlerRegistryAppend:
"""Tests for StarHandlerRegistry.append()."""
def test_append_adds_to_map(self, registry, mock_handler):
"""Append adds handler to star_handlers_map."""
handler = mock_handler(EventType.AdapterMessageEvent, "test_handler")
registry.append(handler)
assert registry.star_handlers_map["test_handler"] is handler
def test_append_adds_to_list(self, registry, mock_handler):
"""Append adds handler to _handlers list."""
handler = mock_handler(EventType.AdapterMessageEvent, "test_handler")
registry.append(handler)
assert handler in registry._handlers
def test_append_sets_default_priority(self, registry, mock_handler):
"""Append sets default priority=0 if not specified."""
handler = mock_handler(EventType.AdapterMessageEvent, "test_handler")
registry.append(handler)
assert handler.extras_configs["priority"] == 0
def test_append_preserves_existing_priority(self, registry, mock_handler):
"""Append preserves explicitly set priority."""
handler = mock_handler(
EventType.AdapterMessageEvent,
"test_handler",
priority=5,
)
registry.append(handler)
assert handler.extras_configs["priority"] == 5
def test_append_sorts_by_priority_descending(self, registry, mock_handler):
"""Append keeps handlers sorted by priority (highest first)."""
h1 = mock_handler(EventType.AdapterMessageEvent, "low_priority", priority=1)
h5 = mock_handler(EventType.AdapterMessageEvent, "high_priority", priority=5)
h3 = mock_handler(EventType.AdapterMessageEvent, "mid_priority", priority=3)
registry.append(h1)
registry.append(h5)
registry.append(h3)
# Should be sorted: high(5), mid(3), low(1)
priorities = [h.extras_configs["priority"] for h in registry._handlers]
assert priorities == [5, 3, 1]
class TestStarHandlerRegistryGetByEventType:
"""Tests for StarHandlerRegistry.get_handlers_by_event_type()."""
def test_returns_handlers_matching_event_type(self, registry, mock_handler):
"""Returns only handlers matching the specified event type."""
adapter_handler = mock_handler(EventType.AdapterMessageEvent, "adapter_h")
llm_handler = mock_handler(EventType.OnLLMRequestEvent, "llm_h")
registry.append(adapter_handler)
registry.append(llm_handler)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = MagicMock(activated=True, reserved=False)
result = registry.get_handlers_by_event_type(EventType.AdapterMessageEvent)
assert adapter_handler in result
assert llm_handler not in result
def test_excludes_disabled_handlers(self, registry, mock_handler):
"""Disabled handlers are excluded."""
enabled_h = mock_handler(EventType.AdapterMessageEvent, "enabled", enabled=True)
disabled_h = mock_handler(
EventType.AdapterMessageEvent, "disabled", enabled=False
)
registry.append(enabled_h)
registry.append(disabled_h)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = MagicMock(activated=True, reserved=False)
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent, only_activated=True
)
assert enabled_h in result
assert disabled_h not in result
def test_only_activated_false_bypasses_star_map_check(self, registry, mock_handler):
"""only_activated=False bypasses star_map activation check but still checks handler.enabled."""
enabled_h = mock_handler(EventType.AdapterMessageEvent, "enabled", enabled=True)
disabled_h = mock_handler(
EventType.AdapterMessageEvent, "disabled", enabled=False
)
registry.append(enabled_h)
registry.append(disabled_h)
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent, only_activated=False
)
assert enabled_h in result
# handler.enabled is still checked even with only_activated=False
assert disabled_h not in result
def test_plugin_not_activated_excluded(self, registry, mock_handler):
"""Handlers from deactivated plugins are excluded."""
handler = mock_handler(
EventType.AdapterMessageEvent, "plugin_h", module_path="mod"
)
registry.append(handler)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = MagicMock(activated=False)
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent, only_activated=True
)
assert handler not in result
def test_plugin_not_in_star_map_excluded(self, registry, mock_handler):
"""Handlers whose plugin is not in star_map are excluded."""
handler = mock_handler(
EventType.AdapterMessageEvent, "orphan_h", module_path="orphan"
)
registry.append(handler)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = None
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent, only_activated=True
)
assert handler not in result
def test_plugins_name_whitelist(self, registry, mock_handler):
"""plugins_name filters to specific plugin names."""
handler1 = mock_handler(
EventType.AdapterMessageEvent, "h1", module_path="plugin_a"
)
handler2 = mock_handler(
EventType.AdapterMessageEvent, "h2", module_path="plugin_b"
)
registry.append(handler1)
registry.append(handler2)
def mock_get(path):
m = MagicMock(activated=True, reserved=False)
m.name = path # set name as actual string attribute
return m
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.side_effect = mock_get
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent,
plugins_name=["plugin_a"],
)
assert handler1 in result
assert handler2 not in result
def test_plugins_name_wildcard_all(self, registry, mock_handler):
"""plugins_name=['*'] includes all handlers (bypasses whitelist but not activation check)."""
h1 = mock_handler(EventType.AdapterMessageEvent, "h1", module_path="p1")
h2 = mock_handler(EventType.AdapterMessageEvent, "h2", module_path="p2")
registry.append(h1)
registry.append(h2)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = MagicMock(activated=True, reserved=False)
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent, plugins_name=["*"]
)
assert len(result) == 2
def test_event_type_allowed_even_when_not_in_plugin_list(
self, registry, mock_handler
):
"""Certain event types bypass the plugins_name filter."""
handler = mock_handler(
EventType.OnAstrBotLoadedEvent, "loaded_h", module_path="mod"
)
registry.append(handler)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = MagicMock(
name="mod", activated=True, reserved=False
)
# Should include even though plugin not in plugins_name list
result = registry.get_handlers_by_event_type(
EventType.OnAstrBotLoadedEvent, plugins_name=["other"]
)
assert handler in result
def test_reserved_plugin_bypasses_whitelist(self, registry, mock_handler):
"""Reserved plugins bypass the plugins_name whitelist."""
handler = mock_handler(
EventType.AdapterMessageEvent, "reserved_h", module_path="core_mod"
)
registry.append(handler)
with patch("astrbot.core.star.star_handler.star_map") as mock_map:
mock_map.get.return_value = MagicMock(
name="core", activated=True, reserved=True
)
result = registry.get_handlers_by_event_type(
EventType.AdapterMessageEvent, plugins_name=["other"]
)
assert handler in result
class TestStarHandlerRegistryGetByFullName:
"""Tests for get_handler_by_full_name()."""
def test_returns_handler_by_name(self, registry, mock_handler):
"""Returns the handler with the given full name."""
h1 = mock_handler(EventType.AdapterMessageEvent, "handler_one")
h2 = mock_handler(EventType.AdapterMessageEvent, "handler_two")
registry.append(h1)
registry.append(h2)
result = registry.get_handler_by_full_name("handler_one")
assert result is h1
def test_returns_none_for_missing_name(self, registry):
"""Returns None for a name not in the registry."""
result = registry.get_handler_by_full_name("nonexistent")
assert result is None
class TestStarHandlerRegistryGetByModuleName:
"""Tests for get_handlers_by_module_name()."""
def test_returns_handlers_for_module(self, registry, mock_handler):
"""Returns all handlers from a specific module."""
h1 = mock_handler(EventType.AdapterMessageEvent, "m1_h1", module_path="mod_a")
h2 = mock_handler(EventType.OnLLMRequestEvent, "m1_h2", module_path="mod_a")
h3 = mock_handler(EventType.AdapterMessageEvent, "m2_h1", module_path="mod_b")
registry.append(h1)
registry.append(h2)
registry.append(h3)
result = registry.get_handlers_by_module_name("mod_a")
assert h1 in result
assert h2 in result
assert h3 not in result
def test_returns_empty_for_unknown_module(self, registry):
"""Returns empty list for a module with no handlers."""
result = registry.get_handlers_by_module_name("unknown_module")
assert result == []
class TestStarHandlerRegistryClear:
"""Tests for StarHandlerRegistry.clear()."""
def test_clear_removes_all_handlers(self, registry, mock_handler):
"""clear() empties both maps and lists."""
registry.append(mock_handler(EventType.AdapterMessageEvent, "h1"))
registry.append(mock_handler(EventType.OnLLMRequestEvent, "h2"))
registry.clear()
assert len(registry.star_handlers_map) == 0
assert len(registry._handlers) == 0
class TestStarHandlerRegistryRemove:
"""Tests for StarHandlerRegistry.remove()."""
def test_remove_existing_handler(self, registry, mock_handler):
"""remove() removes the specified handler."""
h1 = mock_handler(EventType.AdapterMessageEvent, "h1")
h2 = mock_handler(EventType.AdapterMessageEvent, "h2")
registry.append(h1)
registry.append(h2)
registry.remove(h1)
assert "h1" not in registry.star_handlers_map
assert h1 not in registry._handlers
assert "h2" in registry.star_handlers_map
def test_remove_nonexistent_no_error(self, registry, mock_handler):
"""remove() of non-existent handler does not raise."""
handler = mock_handler(EventType.AdapterMessageEvent, "h1")
registry.remove(handler) # Should not raise
class TestStarHandlerRegistryIteration:
"""Tests for __iter__ and __len__."""
def test_iter_yields_handlers(self, registry, mock_handler):
"""__iter__ yields all handlers in priority order."""
h1 = mock_handler(EventType.AdapterMessageEvent, "h1")
h2 = mock_handler(EventType.AdapterMessageEvent, "h2")
registry.append(h1)
registry.append(h2)
result = list(registry)
assert h1 in result
assert h2 in result
def test_len_returns_count(self, registry, mock_handler):
"""__len__ returns number of handlers."""
assert len(registry) == 0
registry.append(mock_handler(EventType.AdapterMessageEvent, "h1"))
assert len(registry) == 1
registry.append(mock_handler(EventType.AdapterMessageEvent, "h2"))
assert len(registry) == 2
class TestStarHandlerMetadataPriority:
"""Tests for StarHandlerMetadata.__lt__()."""
def test_lt_lower_priority(self):
"""Handler with lower priority is 'less than' higher priority."""
h_low = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
handler_full_name="low",
handler_name="low",
handler_module_path="m",
handler=MagicMock(),
event_filters=[],
extras_configs={"priority": 1},
)
h_high = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
handler_full_name="high",
handler_name="high",
handler_module_path="m",
handler=MagicMock(),
event_filters=[],
extras_configs={"priority": 5},
)
assert (h_low < h_high) is True
assert (h_high < h_low) is False
def test_lt_default_priority(self):
"""Handler with default priority (0) is less than non-zero."""
h_default = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
handler_full_name="default",
handler_name="default",
handler_module_path="m",
handler=MagicMock(),
event_filters=[],
)
h_nonzero = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
handler_full_name="nonzero",
handler_name="nonzero",
handler_module_path="m",
handler=MagicMock(),
event_filters=[],
extras_configs={"priority": 10},
)
assert (h_default < h_nonzero) is True
def test_lt_same_priority(self):
"""Handlers with same priority return False for both comparisons."""
h1 = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
handler_full_name="h1",
handler_name="h1",
handler_module_path="m",
handler=MagicMock(),
event_filters=[],
extras_configs={"priority": 5},
)
h2 = StarHandlerMetadata(
event_type=EventType.AdapterMessageEvent,
handler_full_name="h2",
handler_name="h2",
handler_module_path="m",
handler=MagicMock(),
event_filters=[],
extras_configs={"priority": 5},
)
assert (h1 < h2) is False
assert (h2 < h1) is False

View File

@@ -0,0 +1,297 @@
"""Tests for UmopConfigRouter."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from astrbot.core.umop_config_router import UmopConfigRouter
@pytest.fixture
def mock_sp():
"""Create a mock SharedPreferences."""
sp = AsyncMock()
sp.get_async = AsyncMock(return_value={})
sp.global_put = AsyncMock()
return sp
@pytest.fixture
def router(mock_sp):
"""Create an UmopConfigRouter instance."""
return UmopConfigRouter(mock_sp)
class TestSplitUmo:
"""Tests for _split_umo static method."""
def test_valid_umo_three_parts(self):
"""Split a valid UMO with three parts."""
result = UmopConfigRouter._split_umo("telegram:private:12345")
assert result == ("telegram", "private", "12345")
def test_valid_umo_with_colons_in_session(self):
"""UMO with colon in session_id (split on 3 parts max)."""
result = UmopConfigRouter._split_umo("discord:group:channel:123")
assert result == ("discord", "group", "channel:123")
def test_valid_umo_empty_parts(self):
"""UMO with empty parts."""
result = UmopConfigRouter._split_umo("telegram::user_456")
assert result == ("telegram", "", "user_456")
def test_valid_umo_all_empty(self):
"""UMO with all empty parts."""
result = UmopConfigRouter._split_umo("::")
assert result == ("", "", "")
def test_two_parts_returns_none(self):
"""UMO with only two parts is invalid."""
result = UmopConfigRouter._split_umo("telegram:private")
assert result is None
def test_one_part_returns_none(self):
"""UMO with only one part is invalid."""
result = UmopConfigRouter._split_umo("telegram")
assert result is None
def test_non_string_returns_none(self):
"""UMO that is not a string returns None."""
assert UmopConfigRouter._split_umo(None) is None
assert UmopConfigRouter._split_umo(123) is None
def test_four_parts_returns_three(self):
"""UMO with four parts splits to three (last keeps colon)."""
result = UmopConfigRouter._split_umo("a:b:c:d")
assert result == ("a", "b", "c:d")
class TestIsUmoMatch:
"""Tests for _is_umo_match method."""
def test_exact_match(self):
"""Exact UMO matches."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert (
router._is_umo_match("telegram:private:123", "telegram:private:123") is True
)
def test_wildcard_platform(self):
"""Wildcard '*' in pattern matches any platform."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("*:group:456", "telegram:group:456") is True
assert router._is_umo_match("*:group:456", "discord:group:456") is True
def test_wildcard_type(self):
"""Wildcard in type position matches any type."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("telegram:*:123", "telegram:private:123") is True
assert router._is_umo_match("telegram:*:123", "telegram:group:123") is True
def test_wildcard_session(self):
"""Wildcard in session position matches any session."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert (
router._is_umo_match("telegram:private:*", "telegram:private:123") is True
)
assert (
router._is_umo_match("telegram:private:*", "telegram:private:abc") is True
)
def test_fnmatch_patterns(self):
"""fnmatch-style patterns work."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("telegram:group:*", "telegram:group:123") is True
assert router._is_umo_match("*:private:*", "telegram:private:123") is True
assert router._is_umo_match("*:private:*", "discord:private:456") is True
def test_empty_pattern_matches_empty(self):
"""Empty string in pattern matches empty string."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("telegram::123", "telegram::123") is True
def test_non_matching_pattern(self):
"""Pattern that doesn't match returns False."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert (
router._is_umo_match("telegram:private:123", "discord:private:123") is False
)
assert (
router._is_umo_match("telegram:private:123", "telegram:group:123") is False
)
def test_invalid_patternUMO(self):
"""Invalid pattern UMO returns False."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("invalid", "telegram:private:123") is False
def test_invalid_targetUMO(self):
"""Invalid target UMO returns False."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("telegram:private:123", "invalid") is False
def test_both_invalid_return_false(self):
"""Both invalid UMOs return False."""
router = UmopConfigRouter(MagicMock())
router.umop_to_conf_id = {}
assert router._is_umo_match("invalid", "also_invalid") is False
class TestGetConfIdForUmop:
"""Tests for get_conf_id_for_umop method."""
@pytest.mark.asyncio
async def test_finds_matching_route(self, router):
"""Returns conf_id for matching pattern."""
router.umop_to_conf_id = {
"telegram:private:*": "config_1",
"discord:group:*": "config_2",
}
result = router.get_conf_id_for_umop("telegram:private:123")
assert result == "config_1"
@pytest.mark.asyncio
async def test_finds_matching_route_group(self, router):
"""Returns conf_id for group message."""
router.umop_to_conf_id = {
"telegram:group:*": "group_config",
}
result = router.get_conf_id_for_umop("telegram:group:456")
assert result == "group_config"
@pytest.mark.asyncio
async def test_wildcard_pattern_matches(self, router):
"""Wildcard pattern matches correctly."""
router.umop_to_conf_id = {
"*:private:*": "any_private",
}
result = router.get_conf_id_for_umop("discord:private:789")
assert result == "any_private"
@pytest.mark.asyncio
async def test_no_match_returns_none(self, router):
"""No matching pattern returns None."""
router.umop_to_conf_id = {
"telegram:private:*": "config_1",
}
result = router.get_conf_id_for_umop("discord:group:999")
assert result is None
@pytest.mark.asyncio
async def test_empty_routing_table(self, router):
"""Empty routing table returns None."""
router.umop_to_conf_id = {}
result = router.get_conf_id_for_umop("telegram:private:123")
assert result is None
class TestUpdateRoutingData:
"""Tests for update_routing_data method."""
@pytest.mark.asyncio
async def test_valid_routing_update(self, router, mock_sp):
"""Valid routing dict is stored and persisted."""
new_routing = {
"telegram:private:*": "config_telegram",
"discord:group:*": "config_discord",
}
await router.update_routing_data(new_routing)
assert router.umop_to_conf_id == new_routing
mock_sp.global_put.assert_called_once_with("umop_config_routing", new_routing)
@pytest.mark.asyncio
async def test_invalid_key_raises(self, router):
"""Invalid UMO key raises ValueError."""
new_routing = {
"invalid_umo": "config_1",
}
with pytest.raises(ValueError, match="umop keys must be"):
await router.update_routing_data(new_routing)
@pytest.mark.asyncio
async def test_one_invalid_key_raises(self, router):
"""One invalid key among valid keys raises ValueError."""
new_routing = {
"telegram:private:*": "config_1",
"invalid": "config_2",
}
with pytest.raises(ValueError, match="umop keys must be"):
await router.update_routing_data(new_routing)
class TestUpdateRoute:
"""Tests for update_route method."""
@pytest.mark.asyncio
async def test_valid_route_update(self, router, mock_sp):
"""Valid umo and conf_id updates route and persists."""
await router.update_route("telegram:group:*", "new_config")
assert router.umop_to_conf_id["telegram:group:*"] == "new_config"
mock_sp.global_put.assert_called_once()
@pytest.mark.asyncio
async def test_invalid_umo_raises(self, router):
"""Invalid UMO raises ValueError."""
with pytest.raises(ValueError, match="umop must be a string"):
await router.update_route("invalid", "conf")
@pytest.mark.asyncio
async def test_invalid_type_raises(self, router):
"""Invalid type raises ValueError."""
with pytest.raises(ValueError, match="umop must be a string"):
await router.update_route("only_two_parts", "conf")
class TestDeleteRoute:
"""Tests for delete_route method."""
@pytest.mark.asyncio
async def test_delete_existing_route(self, router, mock_sp):
"""Deleting existing route removes it and persists."""
router.umop_to_conf_id = {
"telegram:private:*": "config_1",
"discord:group:*": "config_2",
}
await router.delete_route("telegram:private:*")
assert "telegram:private:*" not in router.umop_to_conf_id
assert "discord:group:*" in router.umop_to_conf_id
mock_sp.global_put.assert_called_once()
@pytest.mark.asyncio
async def test_delete_nonexistent_route_no_persist(self, router, mock_sp):
"""Deleting non-existent route does NOT call persist (early return)."""
router.umop_to_conf_id = {}
await router.delete_route("telegram:private:*")
mock_sp.global_put.assert_not_called()
@pytest.mark.asyncio
async def test_delete_invalid_umo_raises(self, router):
"""Deleting invalid UMO raises ValueError."""
with pytest.raises(ValueError, match="umop must be a string"):
await router.delete_route("invalid")
class TestInitialize:
"""Tests for initialize method."""
@pytest.mark.asyncio
async def test_initialize_loads_routing_table(self, router, mock_sp):
"""initialize loads routing table from SharedPreferences."""
mock_sp.get_async.return_value = {
"telegram:private:*": "loaded_config",
}
await router.initialize()
assert router.umop_to_conf_id == {
"telegram:private:*": "loaded_config",
}