mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* feat: enhance plugin page internationalization - Updated PluginRoute to read initial context from JWT and set it in the bridge SDK. - Added methods to retrieve locale and plugin metadata for better i18n support. - Enhanced pluginI18n utility to resolve page-specific translations and added new functions for page titles and descriptions. - Modified PluginPagePage and PluginDetailPage to utilize new i18n features for dynamic content rendering. - Improved documentation for plugin page i18n structure and usage. - Added tests to verify the correct integration of i18n in plugin pages and context handling. * fix test
590 lines
16 KiB
Python
590 lines
16 KiB
Python
"""测试辅助函数和工具类。
|
||
|
||
提供统一的测试辅助工具,减少测试代码重复。
|
||
"""
|
||
|
||
import shutil
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
from astrbot.core.message.components import BaseMessageComponent
|
||
|
||
|
||
class NoopAwaitable:
|
||
"""可等待的空操作对象。
|
||
|
||
用于 mock 需要返回 awaitable 对象的方法。
|
||
"""
|
||
|
||
def __await__(self):
|
||
if False:
|
||
yield
|
||
return None
|
||
|
||
|
||
# ============================================================
|
||
# 平台配置工厂
|
||
# ============================================================
|
||
|
||
|
||
def make_platform_config(platform_type: str, **kwargs) -> dict:
|
||
"""平台配置工厂函数。
|
||
|
||
Args:
|
||
platform_type: 平台类型 (telegram, discord, aiocqhttp 等)
|
||
**kwargs: 覆盖默认配置的字段
|
||
|
||
Returns:
|
||
dict: 平台配置字典
|
||
"""
|
||
configs = {
|
||
"telegram": {
|
||
"id": "test_telegram",
|
||
"telegram_token": "test_token_123",
|
||
"telegram_api_base_url": "https://api.telegram.org/bot",
|
||
"telegram_file_base_url": "https://api.telegram.org/file/bot",
|
||
"telegram_command_register": True,
|
||
"telegram_command_auto_refresh": True,
|
||
"telegram_command_register_interval": 300,
|
||
"telegram_media_group_timeout": 2.5,
|
||
"telegram_media_group_max_wait": 10.0,
|
||
"start_message": "Welcome to AstrBot!",
|
||
},
|
||
"discord": {
|
||
"id": "test_discord",
|
||
"discord_token": "test_token_123",
|
||
"discord_proxy": None,
|
||
"discord_command_register": True,
|
||
"discord_guild_id_for_debug": None,
|
||
"discord_activity_name": "Playing AstrBot",
|
||
},
|
||
"aiocqhttp": {
|
||
"id": "test_aiocqhttp",
|
||
"ws_reverse_host": "0.0.0.0",
|
||
"ws_reverse_port": 6199,
|
||
"ws_reverse_token": "test_token",
|
||
},
|
||
"webchat": {
|
||
"id": "test_webchat",
|
||
},
|
||
"wecom": {
|
||
"id": "test_wecom",
|
||
"wecom_corpid": "test_corpid",
|
||
"wecom_secret": "test_secret",
|
||
},
|
||
}
|
||
config = configs.get(platform_type, {"id": f"test_{platform_type}"}).copy()
|
||
config.update(kwargs)
|
||
return config
|
||
|
||
|
||
# ============================================================
|
||
# Telegram 辅助函数
|
||
# ============================================================
|
||
|
||
|
||
def create_mock_update(
|
||
message_text: str | None = "Hello World",
|
||
chat_type: str = "private",
|
||
chat_id: int = 123456789,
|
||
user_id: int = 987654321,
|
||
username: str = "test_user",
|
||
message_id: int = 1,
|
||
media_group_id: str | None = None,
|
||
photo: list | None = None,
|
||
video: MagicMock | None = None,
|
||
document: MagicMock | None = None,
|
||
voice: MagicMock | None = None,
|
||
sticker: MagicMock | None = None,
|
||
reply_to_message: MagicMock | None = None,
|
||
caption: str | None = None,
|
||
entities: list | None = None,
|
||
caption_entities: list | None = None,
|
||
message_thread_id: int | None = None,
|
||
is_topic_message: bool = False,
|
||
):
|
||
"""创建模拟的 Telegram Update 对象。
|
||
|
||
Args:
|
||
message_text: 消息文本
|
||
chat_type: 聊天类型
|
||
chat_id: 聊天 ID
|
||
user_id: 用户 ID
|
||
username: 用户名
|
||
message_id: 消息 ID
|
||
media_group_id: 媒体组 ID
|
||
photo: 图片列表
|
||
video: 视频对象
|
||
document: 文档对象
|
||
voice: 语音对象
|
||
sticker: 贴纸对象
|
||
reply_to_message: 回复的消息
|
||
caption: 说明文字
|
||
entities: 实体列表
|
||
caption_entities: 说明实体列表
|
||
message_thread_id: 消息线程 ID
|
||
is_topic_message: 是否为主题消息
|
||
|
||
Returns:
|
||
MagicMock: 模拟的 Update 对象
|
||
"""
|
||
update = MagicMock()
|
||
update.update_id = 1
|
||
|
||
# Create message mock
|
||
message = MagicMock()
|
||
message.message_id = message_id
|
||
message.chat = MagicMock()
|
||
message.chat.id = chat_id
|
||
message.chat.type = chat_type
|
||
message.message_thread_id = message_thread_id
|
||
message.is_topic_message = is_topic_message
|
||
|
||
# Create user mock
|
||
from_user = MagicMock()
|
||
from_user.id = user_id
|
||
from_user.username = username
|
||
message.from_user = from_user
|
||
|
||
# Set message content
|
||
message.text = message_text
|
||
message.media_group_id = media_group_id
|
||
message.photo = photo
|
||
message.video = video
|
||
message.document = document
|
||
message.voice = voice
|
||
message.sticker = sticker
|
||
message.reply_to_message = reply_to_message
|
||
message.caption = caption
|
||
message.entities = entities
|
||
message.caption_entities = caption_entities
|
||
|
||
update.message = message
|
||
update.effective_chat = message.chat
|
||
|
||
return update
|
||
|
||
|
||
def create_mock_file(file_path: str = "https://api.telegram.org/file/test.jpg"):
|
||
"""创建模拟的 Telegram File 对象。
|
||
|
||
Args:
|
||
file_path: 文件路径
|
||
|
||
Returns:
|
||
MagicMock: 模拟的 File 对象
|
||
"""
|
||
file = MagicMock()
|
||
file.file_path = file_path
|
||
file.get_file = AsyncMock(return_value=file)
|
||
return file
|
||
|
||
|
||
# ============================================================
|
||
# Discord 辅助函数
|
||
# ============================================================
|
||
|
||
|
||
def create_mock_discord_attachment(
|
||
filename: str = "test.txt",
|
||
url: str = "https://cdn.discordapp.com/test.txt",
|
||
content_type: str | None = None,
|
||
size: int = 1024,
|
||
):
|
||
"""创建模拟的 Discord Attachment 对象。
|
||
|
||
Args:
|
||
filename: 文件名
|
||
url: 文件 URL
|
||
content_type: 内容类型
|
||
size: 文件大小
|
||
|
||
Returns:
|
||
MagicMock: 模拟的 Attachment 对象
|
||
"""
|
||
attachment = MagicMock()
|
||
attachment.filename = filename
|
||
attachment.url = url
|
||
attachment.content_type = content_type
|
||
attachment.size = size
|
||
return attachment
|
||
|
||
|
||
def create_mock_discord_user(
|
||
user_id: int = 123456789,
|
||
name: str = "TestUser",
|
||
display_name: str = "Test User",
|
||
bot: bool = False,
|
||
):
|
||
"""创建模拟的 Discord User 对象。
|
||
|
||
Args:
|
||
user_id: 用户 ID
|
||
name: 用户名
|
||
display_name: 显示名
|
||
bot: 是否为机器人
|
||
|
||
Returns:
|
||
MagicMock: 模拟的 User 对象
|
||
"""
|
||
user = MagicMock()
|
||
user.id = user_id
|
||
user.name = name
|
||
user.display_name = display_name
|
||
user.bot = bot
|
||
user.mention = f"<@{user_id}>"
|
||
return user
|
||
|
||
|
||
def create_mock_discord_channel(
|
||
channel_id: int = 111222333,
|
||
channel_type: str = "text",
|
||
name: str = "general",
|
||
guild_id: int | None = 444555666,
|
||
):
|
||
"""创建模拟的 Discord Channel 对象。
|
||
|
||
Args:
|
||
channel_id: 频道 ID
|
||
channel_type: 频道类型
|
||
name: 频道名
|
||
guild_id: 服务器 ID
|
||
|
||
Returns:
|
||
MagicMock: 模拟的 Channel 对象
|
||
"""
|
||
channel = MagicMock()
|
||
channel.id = channel_id
|
||
channel.name = name
|
||
channel.type = channel_type
|
||
|
||
if guild_id:
|
||
channel.guild = MagicMock()
|
||
channel.guild.id = guild_id
|
||
else:
|
||
channel.guild = None
|
||
|
||
return channel
|
||
|
||
|
||
# ============================================================
|
||
# 消息组件辅助函数
|
||
# ============================================================
|
||
|
||
|
||
def create_mock_message_component(
|
||
component_type: str,
|
||
**kwargs: Any,
|
||
) -> BaseMessageComponent:
|
||
"""创建模拟的消息组件。
|
||
|
||
Args:
|
||
component_type: 组件类型 (plain, image, at, reply, file)
|
||
**kwargs: 组件参数
|
||
|
||
Returns:
|
||
BaseMessageComponent: 消息组件实例
|
||
"""
|
||
from astrbot.core.message import components as Comp
|
||
|
||
component_map = {
|
||
"plain": Comp.Plain,
|
||
"image": Comp.Image,
|
||
"at": Comp.At,
|
||
"reply": Comp.Reply,
|
||
"file": Comp.File,
|
||
}
|
||
|
||
component_class = component_map.get(component_type.lower())
|
||
if not component_class:
|
||
raise ValueError(f"Unknown component type: {component_type}")
|
||
|
||
return component_class(**kwargs)
|
||
|
||
|
||
def create_mock_llm_response(
|
||
completion_text: str = "Hello! How can I help you?",
|
||
role: str = "assistant",
|
||
tools_call_name: list[str] | None = None,
|
||
tools_call_args: list[dict] | None = None,
|
||
tools_call_ids: list[str] | None = None,
|
||
):
|
||
"""创建模拟的 LLM 响应。
|
||
|
||
Args:
|
||
completion_text: 完成文本
|
||
role: 角色
|
||
tools_call_name: 工具调用名称列表
|
||
tools_call_args: 工具调用参数列表
|
||
tools_call_ids: 工具调用 ID 列表
|
||
|
||
Returns:
|
||
LLMResponse: 模拟的 LLM 响应
|
||
"""
|
||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||
|
||
return LLMResponse(
|
||
role=role,
|
||
completion_text=completion_text,
|
||
tools_call_name=tools_call_name or [],
|
||
tools_call_args=tools_call_args or [],
|
||
tools_call_ids=tools_call_ids or [],
|
||
usage=TokenUsage(input_other=10, output=5),
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# 测试插件辅助函数
|
||
# ============================================================
|
||
|
||
|
||
@dataclass
|
||
class MockPluginConfig:
|
||
"""测试插件配置。
|
||
|
||
用于创建和管理测试用的模拟插件。
|
||
|
||
Attributes:
|
||
name: 插件名称
|
||
author: 作者
|
||
description: 描述
|
||
version: 版本
|
||
repo: 仓库 URL
|
||
main_code: main.py 的代码内容
|
||
requirements: 依赖列表
|
||
has_readme: 是否创建 README.md
|
||
readme_content: README.md 内容
|
||
"""
|
||
|
||
name: str = "test_plugin"
|
||
author: str = "Test Author"
|
||
description: str = "A test plugin for unit testing"
|
||
version: str = "1.0.0"
|
||
repo: str = "https://github.com/test/test_plugin"
|
||
main_code: str = ""
|
||
requirements: list[str] = field(default_factory=list)
|
||
has_readme: bool = True
|
||
readme_content: str = "# Test Plugin\n\nThis is a test plugin."
|
||
|
||
|
||
# 默认的插件主代码模板
|
||
DEFAULT_PLUGIN_MAIN_TEMPLATE = '''
|
||
from astrbot.api import star
|
||
|
||
class Main(star.Star):
|
||
"""测试插件主类。"""
|
||
|
||
def __init__(self, context):
|
||
super().__init__(context)
|
||
self.name = "{plugin_name}"
|
||
|
||
async def initialize(self):
|
||
"""初始化插件。"""
|
||
pass
|
||
|
||
async def terminate(self):
|
||
"""终止插件。"""
|
||
pass
|
||
'''
|
||
|
||
|
||
class MockPluginBuilder:
|
||
"""测试插件构建器。
|
||
|
||
用于创建、管理和清理测试用的模拟插件。支持任意插件的模拟创建。
|
||
|
||
Example:
|
||
# 创建一个简单的测试插件
|
||
builder = MockPluginBuilder(plugin_store_path)
|
||
plugin_dir = builder.create("my_test_plugin")
|
||
|
||
# 创建自定义配置的插件
|
||
config = MockPluginConfig(
|
||
name="custom_plugin",
|
||
version="2.0.0",
|
||
main_code="print('hello')",
|
||
)
|
||
plugin_dir = builder.create(config)
|
||
|
||
# 清理插件
|
||
builder.cleanup("my_test_plugin")
|
||
"""
|
||
|
||
def __init__(self, plugin_store_path: str | Path):
|
||
"""初始化构建器。
|
||
|
||
Args:
|
||
plugin_store_path: 插件存储路径 (通常是 data/plugins)
|
||
"""
|
||
self.plugin_store_path = Path(plugin_store_path)
|
||
self._created_plugins: set[str] = set()
|
||
|
||
def create(
|
||
self,
|
||
plugin_config: str | MockPluginConfig | None = None,
|
||
**kwargs,
|
||
) -> Path:
|
||
"""创建模拟插件。
|
||
|
||
Args:
|
||
plugin_config: 插件名称字符串、MockPluginConfig 对象或 None
|
||
**kwargs: 如果 plugin_config 是字符串或 None,这些参数用于构建 MockPluginConfig
|
||
|
||
Returns:
|
||
Path: 创建的插件目录路径
|
||
"""
|
||
# 处理不同类型的输入
|
||
if plugin_config is None:
|
||
config = MockPluginConfig(**kwargs)
|
||
elif isinstance(plugin_config, str):
|
||
config = MockPluginConfig(name=plugin_config, **kwargs)
|
||
elif isinstance(plugin_config, MockPluginConfig):
|
||
config = plugin_config
|
||
else:
|
||
raise TypeError(f"Invalid plugin_config type: {type(plugin_config)}")
|
||
|
||
# 创建插件目录
|
||
plugin_dir = self.plugin_store_path / config.name
|
||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 创建 metadata.yaml
|
||
metadata_content = "\n".join(
|
||
[
|
||
f"name: {config.name}",
|
||
f"author: {config.author}",
|
||
f"desc: {config.description}",
|
||
f"version: {config.version}",
|
||
f"repo: {config.repo}",
|
||
]
|
||
)
|
||
(plugin_dir / "metadata.yaml").write_text(
|
||
metadata_content + "\n", encoding="utf-8"
|
||
)
|
||
|
||
# 创建 main.py
|
||
main_code = config.main_code or DEFAULT_PLUGIN_MAIN_TEMPLATE.format(
|
||
plugin_name=config.name
|
||
)
|
||
(plugin_dir / "main.py").write_text(main_code, encoding="utf-8")
|
||
|
||
# 创建 requirements.txt(如果有依赖)
|
||
if config.requirements:
|
||
(plugin_dir / "requirements.txt").write_text(
|
||
"\n".join(config.requirements) + "\n", encoding="utf-8"
|
||
)
|
||
|
||
# 创建 README.md(如果需要)
|
||
if config.has_readme:
|
||
(plugin_dir / "README.md").write_text(
|
||
config.readme_content, encoding="utf-8"
|
||
)
|
||
|
||
# 记录创建的插件
|
||
self._created_plugins.add(config.name)
|
||
|
||
return plugin_dir
|
||
|
||
def cleanup(self, plugin_name: str | None = None) -> None:
|
||
"""清理插件。
|
||
|
||
Args:
|
||
plugin_name: 要清理的插件名称,如果为 None 则清理所有由本构建器创建的插件
|
||
"""
|
||
if plugin_name:
|
||
plugins_to_clean = {plugin_name}
|
||
else:
|
||
plugins_to_clean = self._created_plugins.copy()
|
||
|
||
for name in plugins_to_clean:
|
||
plugin_dir = self.plugin_store_path / name
|
||
if plugin_dir.exists():
|
||
shutil.rmtree(plugin_dir)
|
||
self._created_plugins.discard(name)
|
||
|
||
def cleanup_all(self) -> None:
|
||
"""清理所有由本构建器创建的插件。"""
|
||
self.cleanup(None)
|
||
|
||
def get_plugin_path(self, plugin_name: str) -> Path:
|
||
"""获取插件路径。
|
||
|
||
Args:
|
||
plugin_name: 插件名称
|
||
|
||
Returns:
|
||
Path: 插件目录路径
|
||
"""
|
||
return self.plugin_store_path / plugin_name
|
||
|
||
@property
|
||
def created_plugins(self) -> set[str]:
|
||
"""获取已创建的插件名称集合。"""
|
||
return self._created_plugins.copy()
|
||
|
||
|
||
def create_mock_updater_install(
|
||
plugin_builder: MockPluginBuilder,
|
||
repo_to_plugin: dict[str, str] | None = None,
|
||
) -> Callable:
|
||
"""创建模拟的 updater.install 方法。
|
||
|
||
Args:
|
||
plugin_builder: MockPluginBuilder 实例
|
||
repo_to_plugin: 仓库 URL 到插件名称的映射,格式: {"https://github.com/user/repo": "plugin_name"}
|
||
|
||
Returns:
|
||
Callable: 异步函数,可用于 monkeypatch.setattr
|
||
"""
|
||
|
||
async def mock_install(repo_url: str, proxy: str = "") -> str:
|
||
"""Mock updater.install 方法。"""
|
||
# 查找插件名称
|
||
plugin_name = None
|
||
if repo_to_plugin:
|
||
plugin_name = repo_to_plugin.get(repo_url)
|
||
|
||
# 如果没有映射,尝试从 URL 提取插件名
|
||
if not plugin_name:
|
||
# 从 https://github.com/user/plugin_name 提取 plugin_name
|
||
parts = repo_url.rstrip("/").split("/")
|
||
plugin_name = parts[-1] if parts else "unknown_plugin"
|
||
|
||
# 创建插件目录
|
||
config = MockPluginConfig(name=plugin_name, repo=repo_url)
|
||
plugin_dir = plugin_builder.create(config)
|
||
return str(plugin_dir)
|
||
|
||
return mock_install
|
||
|
||
|
||
def create_mock_updater_update(
|
||
plugin_builder: MockPluginBuilder,
|
||
update_callback: Callable | None = None,
|
||
) -> Callable:
|
||
"""创建模拟的 updater.update 方法。
|
||
|
||
Args:
|
||
plugin_builder: MockPluginBuilder 实例
|
||
update_callback: 更新回调函数,接收 plugin 参数
|
||
|
||
Returns:
|
||
Callable: 异步函数,可用于 monkeypatch.setattr
|
||
"""
|
||
|
||
async def mock_update(plugin, proxy: str = "", download_url: str = "") -> None:
|
||
"""Mock updater.update 方法。"""
|
||
del proxy, download_url
|
||
plugin_dir = plugin_builder.get_plugin_path(plugin.name)
|
||
|
||
# 创建更新标记文件
|
||
(plugin_dir / ".updated").write_text("ok", encoding="utf-8")
|
||
|
||
# 调用回调
|
||
if update_callback:
|
||
update_callback(plugin)
|
||
|
||
return mock_update
|