"""通知图片测试命令测试:命令/别名、固定预览内容、群聊/私聊场景与发送约定。 直接导入插件包会触发 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"]))