539 lines
17 KiB
Python
539 lines
17 KiB
Python
"""通知图片测试命令测试:命令/别名、固定预览内容、群聊/私聊场景与发送约定。
|
||
|
||
直接导入插件包会触发 NoneBot 插件初始化,因此这里按路径加载 test_command.py,
|
||
同级模块用轻量替身(text_parser 用真实实现,命令匹配与权限用真实 NoneBot 规则)。
|
||
"""
|
||
import ast
|
||
import asyncio
|
||
import base64
|
||
import importlib.util
|
||
import io
|
||
import logging
|
||
import os
|
||
import sys
|
||
import types
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
|
||
import nonebot
|
||
import pytest
|
||
from nonebot.adapters.onebot.v11 import Message, MessageSegment
|
||
from nonebot.adapters.onebot.v11.event import MessageEvent
|
||
from nonebot.rule import CommandRule
|
||
|
||
PLUGIN_DIR = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "danding_bot"
|
||
/ "plugins"
|
||
/ "danding_qqpush"
|
||
).resolve()
|
||
PACKAGE_NAME = "danding_bot.plugins.danding_qqpush"
|
||
|
||
# 与 .env 中的 SUPERUSERS 一致,用于权限判定
|
||
SUPERUSER_ID = 1424473282
|
||
NON_SUPERUSER_ID = 10001111
|
||
|
||
|
||
class ConfigStub:
|
||
"""渲染器所需配置字段的轻量替身。"""
|
||
|
||
Token = "danding-test"
|
||
ImageWidth = 800
|
||
ImageFontSize = 24
|
||
ImagePadding = 30
|
||
ImageLineSpacing = 1.4
|
||
ImageBgColor = (252, 252, 252)
|
||
ImageTextColor = (0, 0, 0)
|
||
MaxTextLength = 2000
|
||
FontPaths = ["C:/Windows/Fonts/msyh.ttc"]
|
||
|
||
|
||
class ImageRendererStub:
|
||
"""只记录构造参数的替身,用于验证渲染器按配置构建。"""
|
||
|
||
def __init__(self, **kwargs):
|
||
self.kwargs = kwargs
|
||
|
||
def render_to_base64(self, text, title=None, scene="common"):
|
||
return "base64://" + base64.b64encode(b"fake-png").decode("utf-8")
|
||
|
||
|
||
class FakeMessageEvent:
|
||
"""仅保留 scene 判定需要的字段。"""
|
||
|
||
def __init__(self, group_id=None, user_id=10001):
|
||
self.group_id = group_id
|
||
self.user_id = user_id
|
||
|
||
|
||
class FakeMatcher:
|
||
"""记录 finish 调用(失败提示走这里)。"""
|
||
|
||
def __init__(self):
|
||
self.finished = []
|
||
|
||
async def finish(self, message=None, **kwargs):
|
||
self.finished.append(message)
|
||
|
||
|
||
class RecordingBot:
|
||
"""记录 bot.send 的 (event, message)。"""
|
||
|
||
def __init__(self):
|
||
self.sent = []
|
||
|
||
async def send(self, event, message, **kwargs):
|
||
self.sent.append((event, message))
|
||
return {"message_id": len(self.sent)}
|
||
|
||
|
||
def build_permission_bot(driver):
|
||
"""构造权限判定用的真实 OneBot V11 Bot。
|
||
|
||
matcher.permission 经 NoneBot 依赖注入会校验 bot 类型,
|
||
必须是真正的 nonebot.adapters.Bot,所以用真实 Adapter + Bot,
|
||
并把已初始化驱动配置中的超级用户列表(字符串化)拷进 Bot 配置,
|
||
保证超级用户判定与真实运行时一致且不产生 pydantic 归一化告警。
|
||
"""
|
||
from nonebot.adapters.onebot.v11.adapter import Adapter
|
||
from nonebot.adapters.onebot.v11.bot import Bot
|
||
|
||
bot = Bot(Adapter(driver), self_id="10000")
|
||
bot.config.superusers = {str(user_id) for user_id in driver.config.superusers}
|
||
return bot
|
||
|
||
|
||
@contextmanager
|
||
def silence_nonebot_logs():
|
||
"""初始化期间完全静默 NoneBot 日志,避免把 .env 配置打进测试输出。"""
|
||
logging.getLogger("nonebot").setLevel(logging.CRITICAL + 1)
|
||
try:
|
||
yield
|
||
finally:
|
||
logging.getLogger("nonebot").setLevel(logging.CRITICAL)
|
||
|
||
|
||
def ensure_nonebot_initialized():
|
||
"""on_command 需要已初始化的 NoneBot:只创建驱动,不启动服务、不加载插件。"""
|
||
try:
|
||
nonebot.get_driver()
|
||
except ValueError:
|
||
with silence_nonebot_logs():
|
||
nonebot.init(
|
||
log_level="CRITICAL",
|
||
driver="~fastapi+~websockets",
|
||
host="127.0.0.1",
|
||
port=18080,
|
||
)
|
||
driver = nonebot.get_driver()
|
||
# 保证超级用户判定不受 .env 的 SUPERUSERS 配置影响;
|
||
# 与 .env 一致使用字符串形式,避免驱动配置被 int/str 混装归一化
|
||
if str(SUPERUSER_ID) not in driver.config.superusers:
|
||
driver.config.superusers.add(str(SUPERUSER_ID))
|
||
return driver
|
||
|
||
|
||
def _load_module(name, path):
|
||
spec = importlib.util.spec_from_file_location(f"{PACKAGE_NAME}.{name}", path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[spec.name] = module
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def load_test_command():
|
||
"""按路径加载 test_command.py,同时用替身满足同级模块依赖。"""
|
||
if PACKAGE_NAME in sys.modules:
|
||
return sys.modules[PACKAGE_NAME + ".test_command"]
|
||
|
||
ensure_nonebot_initialized()
|
||
|
||
package = types.ModuleType(PACKAGE_NAME)
|
||
package.__path__ = [str(PLUGIN_DIR)]
|
||
sys.modules[PACKAGE_NAME] = package
|
||
|
||
# text_parser 用真实实现:预览文本要经它把 # 解析为换行
|
||
_load_module("text_parser", PLUGIN_DIR / "text_parser.py")
|
||
|
||
config_module = types.ModuleType(PACKAGE_NAME + ".config")
|
||
config_module.Config = ConfigStub
|
||
sys.modules[config_module.__name__] = config_module
|
||
|
||
image_render_module = types.ModuleType(PACKAGE_NAME + ".image_render")
|
||
image_render_module.ImageRenderer = ImageRendererStub
|
||
sys.modules[image_render_module.__name__] = image_render_module
|
||
|
||
# preview_image_render 是 test_command 唯一依赖的同级辅助模块
|
||
_load_module("preview_image_render", PLUGIN_DIR / "preview_image_render.py")
|
||
|
||
module = _load_module("test_command", PLUGIN_DIR / "test_command.py")
|
||
# 供 handler 测试使用的插件配置替身
|
||
package.plugin_config = ConfigStub()
|
||
return module
|
||
|
||
|
||
def load_real_image_renderer():
|
||
"""按路径加载真实渲染模块(不引入 NoneBot)。"""
|
||
name = PACKAGE_NAME + ".real_image_render"
|
||
if name in sys.modules:
|
||
return sys.modules[name]
|
||
# 真实 image_render 不含相对导入,直接按路径加载即可
|
||
path = PLUGIN_DIR / "image_render.py"
|
||
spec = importlib.util.spec_from_file_location(name, path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[spec.name] = module
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def source_tree():
|
||
tree = ast.parse((PLUGIN_DIR / "test_command.py").read_text(encoding="utf-8"))
|
||
if os.getenv("DSH_DEBUG_QQPUSH"):
|
||
print(
|
||
"\nDEBUG async nodes:",
|
||
[
|
||
(n.name, [is_handle_decorator(d) for d in n.decorator_list])
|
||
for n in ast.walk(tree)
|
||
if isinstance(n, ast.AsyncFunctionDef)
|
||
],
|
||
)
|
||
return tree
|
||
|
||
|
||
def source_text() -> str:
|
||
return (PLUGIN_DIR / "test_command.py").read_text(encoding="utf-8")
|
||
|
||
|
||
def build_event(text: str, group_id: int | None = None, user_id: int = SUPERUSER_ID):
|
||
"""构造 OneBot V11 消息事件,用于命令匹配与权限判定。"""
|
||
if group_id is None:
|
||
return MessageEvent(
|
||
time=0,
|
||
self_id=10000,
|
||
post_type="message",
|
||
sub_type="friend",
|
||
user_id=user_id,
|
||
message_type="private",
|
||
message_id=1,
|
||
message=Message(text),
|
||
original_message=Message(text),
|
||
raw_message=text,
|
||
font=0,
|
||
sender={"user_id": user_id},
|
||
)
|
||
return MessageEvent(
|
||
time=0,
|
||
self_id=10000,
|
||
post_type="message",
|
||
sub_type="normal",
|
||
user_id=user_id,
|
||
message_type="group",
|
||
message_id=1,
|
||
message=Message(text),
|
||
original_message=Message(text),
|
||
raw_message=text,
|
||
font=0,
|
||
sender={"user_id": user_id},
|
||
group_id=group_id,
|
||
)
|
||
|
||
|
||
def command_matches(matcher, event) -> bool:
|
||
"""按 NoneBot 依赖注入的语义执行命令匹配规则。"""
|
||
state: dict = {}
|
||
for checker in matcher.rule.checkers:
|
||
if isinstance(checker.call, CommandRule):
|
||
state = {
|
||
"_prefix": {
|
||
"command": (event.get_plaintext().strip(),),
|
||
"command_arg": None,
|
||
"command_whitespace": None,
|
||
}
|
||
}
|
||
break
|
||
return asyncio.run(matcher.rule(build_permission_bot(nonebot.get_driver()), event, state))
|
||
|
||
|
||
def has_permission(matcher, event) -> bool:
|
||
"""按 NoneBot 运行时约定调用 matcher 的 permission(Superuser 需要 bot.adapter/bot.config)。"""
|
||
return asyncio.run(
|
||
matcher.permission(build_permission_bot(nonebot.get_driver()), event)
|
||
)
|
||
|
||
|
||
def test_main_command_and_aliases_are_registered_and_match_plain_messages():
|
||
"""主命令「测试通知图片」与两个别名都要能直接触发(群聊、私聊都不需要 @机器人)。"""
|
||
module = load_test_command()
|
||
matcher = module.test_notification_image
|
||
|
||
assert module.TEST_COMMAND == "测试通知图片"
|
||
assert set(module.TEST_COMMAND_ALIASES) == {"通知图片测试", "测试通知卡片"}
|
||
|
||
for text in (module.TEST_COMMAND, *module.TEST_COMMAND_ALIASES):
|
||
for group_id in (None, 621016172):
|
||
assert command_matches(
|
||
matcher, build_event(text, group_id)
|
||
), f"命令 {text} 应可触发(group_id={group_id})"
|
||
|
||
# 无关消息不触发
|
||
for text in ("测试通知", "通知图片", "测试通知图片额外内容"):
|
||
assert not command_matches(matcher, build_event(text))
|
||
|
||
|
||
def test_command_is_superuser_only_and_not_at_restricted():
|
||
module = load_test_command()
|
||
matcher = module.test_notification_image
|
||
|
||
call = command_registration_source()
|
||
keywords = keyword_names(call)
|
||
|
||
permission = keywords["permission"]
|
||
assert isinstance(permission, ast.Name) and permission.id == "SUPERUSER"
|
||
assert any(
|
||
isinstance(node, ast.ImportFrom)
|
||
and node.module == "nonebot.permission"
|
||
and any(alias.name == "SUPERUSER" for alias in node.names)
|
||
for node in ast.walk(source_tree())
|
||
)
|
||
|
||
# 群聊与私聊都要能触发:不限制 to_me,也不限制会话类型
|
||
assert "rule" not in keywords
|
||
assert ast.literal_eval(keywords["block"]) is True
|
||
assert "to_me" not in source_text()
|
||
|
||
# 权限判定走真实 NoneBot Permission 对象
|
||
assert has_permission(matcher, build_event(module.TEST_COMMAND)) is True
|
||
assert not has_permission(
|
||
matcher, build_event(module.TEST_COMMAND, user_id=NON_SUPERUSER_ID)
|
||
)
|
||
|
||
|
||
def test_single_handler_is_registered_on_the_matcher():
|
||
handlers = [
|
||
node
|
||
for node in ast.walk(source_tree())
|
||
if isinstance(node, ast.AsyncFunctionDef)
|
||
if any(is_handle_decorator(decorator) for decorator in node.decorator_list)
|
||
]
|
||
assert [node.name for node in handlers] == ["handle_test_notification_image"]
|
||
assert [arg.arg for arg in handlers[0].args.args] == ["matcher", "bot", "event"]
|
||
# 使用当前事件的 Bot 发送,渲染放到线程池
|
||
assert "bot.send(event, build_message(image_base64))" in source_text()
|
||
assert "asyncio.to_thread" in source_text()
|
||
|
||
|
||
def test_plugin_package_registers_the_test_command():
|
||
"""插件包导入时须加载该模块,命令才会真正注册。"""
|
||
source = (PLUGIN_DIR / "__init__.py").read_text(encoding="utf-8")
|
||
|
||
assert any(
|
||
isinstance(node, ast.ImportFrom)
|
||
and node.module is None
|
||
and any(alias.name == "test_command" for alias in node.names)
|
||
for node in ast.walk(ast.parse(source))
|
||
), "插件 __init__.py 应以相对导入加载 test_command"
|
||
assert "测试通知图片" in source
|
||
assert source.index("plugin_config = Config.model_validate") < source.index(
|
||
"from . import test_command"
|
||
), "插件包须先完成配置初始化,再导入测试命令"
|
||
|
||
|
||
def test_preview_content_covers_title_chinese_multiline_emoji_and_punctuation():
|
||
module = load_test_command()
|
||
text = module.preview_text()
|
||
|
||
assert module.PREVIEW_TITLE
|
||
# 标题同时演示 # 作为换行符的用法
|
||
assert module.PREVIEW_TITLE != module.PREVIEW_TITLE.replace("#", "")
|
||
|
||
# 标题、中文、多行、Emoji 与中文标点都要有,方便肉眼核对版式
|
||
assert "#" in text, "预览文本应使用 # 表示换行"
|
||
assert any("\u4e00" <= char <= "\u9fff" for char in text), "预览应包含中文"
|
||
assert len(text.split("#")) >= 3, "预览应为多行内容"
|
||
assert any(char in text for char in ",。、;:!?"), "预览应包含中文标点"
|
||
assert any(ord(char) > 0x1F000 for char in text), "预览应包含 Emoji"
|
||
|
||
# 经 TextParser 解析后,与 PREVIEW_TEXT 完全一致(# 均表示换行)
|
||
parser = sys.modules[PACKAGE_NAME + ".text_parser"].TextParser(
|
||
max_length=ConfigStub.MaxTextLength
|
||
)
|
||
assert parser.parse(text) == module.PREVIEW_TEXT
|
||
assert module.PREVIEW_TEXT.count("\n") == text.count("#")
|
||
assert "#" not in module.PREVIEW_TEXT
|
||
|
||
|
||
def test_scene_resolution_uses_group_for_group_events_and_private_otherwise():
|
||
module = load_test_command()
|
||
|
||
assert module.resolve_scene(FakeMessageEvent(group_id=123456789)) == "group"
|
||
assert module.resolve_scene(FakeMessageEvent()) == "private"
|
||
# group_id=0 视为无效群号,同样回退到私聊
|
||
assert module.resolve_scene(FakeMessageEvent(group_id=0)) == "private"
|
||
assert module.resolve_scene(build_event("测试通知图片", 621016172)) == "group"
|
||
assert module.resolve_scene(build_event("测试通知图片")) == "private"
|
||
|
||
# 两个场景徽章都由现有渲染器提供,测试命令不新增场景
|
||
real = load_real_image_renderer()
|
||
assert "group" in real.ImageRenderer.SCENE_STYLES
|
||
assert "private" in real.ImageRenderer.SCENE_STYLES
|
||
|
||
|
||
def test_render_runs_in_thread_and_passes_scene_and_title(monkeypatch):
|
||
module = load_test_command()
|
||
calls = []
|
||
|
||
class RecordingRenderer:
|
||
def render_to_base64(self, text, title=None, scene="common"):
|
||
calls.append({"text": text, "title": title, "scene": scene})
|
||
return "base64://cG5n"
|
||
|
||
monkeypatch.setattr(module, "get_preview_renderer", lambda config: RecordingRenderer())
|
||
real_to_thread = asyncio.to_thread
|
||
to_thread_calls = []
|
||
|
||
async def spy_to_thread(func, *args, **kwargs):
|
||
to_thread_calls.append(func)
|
||
return await real_to_thread(func, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(module.asyncio, "to_thread", spy_to_thread)
|
||
|
||
for group_id, expected_scene in ((987654321, "group"), (None, "private")):
|
||
calls.clear()
|
||
to_thread_calls.clear()
|
||
matcher = FakeMatcher()
|
||
bot = RecordingBot()
|
||
|
||
asyncio.run(
|
||
module.handle_test_notification_image(
|
||
matcher, bot, FakeMessageEvent(group_id=group_id)
|
||
)
|
||
)
|
||
|
||
assert to_thread_calls, "Pillow 渲染必须放到 asyncio.to_thread"
|
||
assert [call["scene"] for call in calls] == [expected_scene]
|
||
assert calls[0]["title"] == module.PREVIEW_TITLE
|
||
assert calls[0]["text"] == module.PREVIEW_TEXT
|
||
assert calls[0]["text"] == module.preview_text().replace("#", "\n")
|
||
assert not matcher.finished
|
||
|
||
|
||
def test_handler_sends_onebot_v11_image_message_via_event_bot():
|
||
module = load_test_command()
|
||
matcher = FakeMatcher()
|
||
bot = RecordingBot()
|
||
event = FakeMessageEvent(group_id=1)
|
||
|
||
rendered = "base64://" + base64.b64encode(b"png-bytes").decode("utf-8")
|
||
message = module.build_message(rendered)
|
||
|
||
assert message[0].type == "image"
|
||
assert message[0].data["file"] == rendered
|
||
assert module.build_message(rendered) == message
|
||
|
||
asyncio.run(module.handle_test_notification_image(matcher, bot, event))
|
||
|
||
assert len(bot.sent) == 1
|
||
sent_event, sent_message = bot.sent[0]
|
||
assert sent_event is event
|
||
assert sent_message[0].type == "image"
|
||
assert sent_message[0].data["file"].startswith("base64://")
|
||
assert not matcher.finished
|
||
|
||
|
||
def test_render_failure_logs_and_replies_with_readable_hint(monkeypatch):
|
||
module = load_test_command()
|
||
matcher = FakeMatcher()
|
||
bot = RecordingBot()
|
||
logged = []
|
||
|
||
class BrokenRenderer:
|
||
def render_to_base64(self, text, title=None, scene="common"):
|
||
raise RuntimeError("字体缺失")
|
||
|
||
monkeypatch.setattr(module, "get_preview_renderer", lambda config: BrokenRenderer())
|
||
monkeypatch.setattr(module.logger, "error", lambda message: logged.append(str(message)))
|
||
|
||
asyncio.run(module.handle_test_notification_image(matcher, bot, FakeMessageEvent()))
|
||
|
||
assert not bot.sent
|
||
assert len(matcher.finished) == 1
|
||
assert isinstance(matcher.finished[0], str)
|
||
assert "字体缺失" in logged[0]
|
||
assert "scene=private" in logged[0]
|
||
|
||
|
||
def test_preview_renderer_is_built_from_plugin_config_and_reused():
|
||
module = load_test_command()
|
||
helper = sys.modules[PACKAGE_NAME + ".preview_image_render"]
|
||
helper._preview_renderer = None
|
||
|
||
first = module.get_preview_renderer(ConfigStub())
|
||
second = module.get_preview_renderer(ConfigStub())
|
||
|
||
assert isinstance(first, ImageRendererStub)
|
||
assert first is second, "渲染器实例应被缓存复用"
|
||
assert first.kwargs["width"] == ConfigStub.ImageWidth
|
||
assert first.kwargs["font_size"] == ConfigStub.ImageFontSize
|
||
assert first.kwargs["padding"] == ConfigStub.ImagePadding
|
||
assert first.kwargs["line_spacing"] == ConfigStub.ImageLineSpacing
|
||
assert tuple(first.kwargs["bg_color"]) == tuple(ConfigStub.ImageBgColor)
|
||
assert tuple(first.kwargs["text_color"]) == tuple(ConfigStub.ImageTextColor)
|
||
assert list(first.kwargs["font_paths"]) == list(ConfigStub.FontPaths)
|
||
|
||
helper._preview_renderer = None
|
||
|
||
|
||
def test_preview_renders_through_the_shared_renderer():
|
||
"""预览内容必须能真正渲染成通知卡片(使用真实 ImageRenderer)。"""
|
||
module = load_test_command()
|
||
real = load_real_image_renderer()
|
||
renderer = real.ImageRenderer(
|
||
width=800, font_size=24, padding=30, font_paths=ConfigStub.FontPaths
|
||
)
|
||
|
||
png = renderer.render(module.PREVIEW_TEXT, title=module.PREVIEW_TITLE, scene="group")
|
||
assert png.startswith(b"\x89PNG")
|
||
|
||
from PIL import Image
|
||
|
||
image = Image.open(io.BytesIO(png))
|
||
assert image.size[0] == 800
|
||
assert image.size[1] >= real.ImageRenderer.MIN_HEIGHT
|
||
|
||
encoded = renderer.render_to_base64(
|
||
module.PREVIEW_TEXT, module.PREVIEW_TITLE, "private"
|
||
)
|
||
assert encoded.startswith("base64://")
|
||
assert base64.b64decode(encoded[len("base64://"):]) == renderer.render(
|
||
module.PREVIEW_TEXT, title=module.PREVIEW_TITLE, scene="private"
|
||
)
|
||
|
||
|
||
def is_handle_decorator(decorator) -> bool:
|
||
"""识别 @matcher.handle 与 @matcher.handle() 两种等价写法。"""
|
||
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||
return isinstance(target, ast.Attribute) and target.attr == "handle"
|
||
|
||
|
||
def command_registration_source():
|
||
"""解析 test_command.py 中 on_command 的注册调用。"""
|
||
for node in ast.walk(source_tree()):
|
||
if not isinstance(node, ast.Call):
|
||
continue
|
||
func = node.func
|
||
if isinstance(func, ast.Name) and func.id == "on_command":
|
||
return node
|
||
raise AssertionError("test_command.py 中未找到 on_command 注册调用")
|
||
|
||
|
||
def keyword_names(call):
|
||
return {
|
||
keyword.arg: keyword.value
|
||
for keyword in call.keywords
|
||
if keyword.arg is not None
|
||
}
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
raise SystemExit(pytest.main([__file__, "-q"]))
|