feat: 新增通知图片测试命令

This commit is contained in:
2026-09-22 21:50:27 +08:00
parent 87c59a0335
commit f4d3abcd09
9 changed files with 2068 additions and 680 deletions

View File

@@ -0,0 +1,353 @@
"""通知卡片渲染测试:版式、场景徽章、中文/Emoji 折行与降级路径。"""
import base64
import importlib.util
import io
from pathlib import Path
from PIL import Image, ImageDraw
def load_image_renderer():
"""按路径加载渲染模块,避免拉起需要 NoneBot 初始化的插件包。"""
path = Path(__file__).resolve().parents[1] / "danding_bot" / "plugins" / "danding_qqpush" / "image_render.py"
spec = importlib.util.spec_from_file_location("danding_qqpush_image_render", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_renderer(module, **kwargs):
return module.ImageRenderer(**kwargs)
def install_offline_pilmoji(monkeypatch, module):
"""用不访问远程 Emoji 源的替身,保证 Emoji 相关渲染测试离线可跑。"""
class OfflinePilmoji:
def __init__(self, image):
self._draw = ImageDraw.Draw(image)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def text(self, xy, text, font=None, fill=None, **kwargs):
self._draw.text(xy, text, font=font, fill=fill)
monkeypatch.setattr(module, "Pilmoji", OfflinePilmoji)
def open_png(data: bytes) -> Image.Image:
return Image.open(io.BytesIO(data)).convert("RGB")
def ink_bbox(image: Image.Image, box=None):
"""返回区域内墨迹(暗色像素)的包围盒,用于判断是否越界。"""
region = image.crop(box) if box else image
mask = region.convert("L").point(lambda value: 255 if value < 200 else 0)
return mask.getbbox()
def ink_pixels(image: Image.Image, box=None) -> int:
"""区域内墨迹像素数量,用于比较某块区域是否新增了文字。"""
region = image.crop(box) if box else image
mask = region.convert("L").point(lambda value: 255 if value < 200 else 0)
return mask.histogram()[255]
def first_ink_row(image: Image.Image, y_from: int, margin: int) -> int:
"""自 y_from 起逐行扫描,返回第一行存在墨迹的 y 坐标。"""
for y in range(y_from, image.size[1] - 1):
if ink_pixels(image, (margin, y, image.size[0] - margin, y + 1)) > 0:
return y
raise AssertionError("区域下方未找到任何墨迹")
def header_bottom(image: Image.Image, margin: int, brand_color) -> int:
"""沿水平中线向下找到蓝色头部结束的位置。"""
width, height = image.size
pixels = image.load()
for y in range(margin, height):
if pixels[width // 2, y] != brand_color:
return y
raise AssertionError("未找到品牌头部下边界")
def color_count(image: Image.Image, box, color) -> int:
colors = image.crop(box).getcolors(maxcolors=image.size[0] * image.size[1])
return dict((pixel, count) for count, pixel in colors).get(color, 0)
def content_limits(renderer, width: int = 800):
margin = renderer.OUTER_MARGIN
card_right = width - margin
return margin + renderer.padding, card_right - renderer.padding
def test_render_keeps_legacy_calls_working():
module = load_image_renderer()
renderer = build_renderer(module)
png = renderer.render("系统告警")
assert png.startswith(b"\x89PNG")
encoded = renderer.render_to_base64("系统告警")
assert encoded.startswith("base64://")
assert base64.b64decode(encoded[len("base64://"):]) == png
# 历史调用方式(位置参数传 title)依然可用
assert renderer.render("系统告警", "自定义标题").startswith(b"\x89PNG")
assert renderer.render_to_base64("系统告警", title="自定义标题").startswith("base64://")
def test_card_uses_cool_gray_backdrop_and_blue_brand_header():
module = load_image_renderer()
renderer = build_renderer(module)
renderer_class = module.ImageRenderer
image = open_png(renderer.render("卡片版式校验", scene="group"))
width, height = image.size
margin = renderer_class.OUTER_MARGIN
assert width == renderer.width == 800
assert image.getpixel((2, 2)) == renderer_class.OUTER_BG_COLOR
assert image.getpixel((width - 3, height - 3)) == renderer_class.OUTER_BG_COLOR
assert image.getpixel((width // 2, margin + 5)) == renderer_class.BRAND_COLOR
assert image.getpixel((width // 2, height - margin - 5)) == renderer.bg_color
def test_scene_badges_distinguish_group_and_private_notifications():
module = load_image_renderer()
renderer = build_renderer(module)
common = renderer.render("同一段通知内容", scene="common")
group = renderer.render("同一段通知内容", scene="group")
private = renderer.render("同一段通知内容", scene="private")
assert len({common, group, private}) == 3
margin = renderer.OUTER_MARGIN
header_box = (margin, margin, 800 - margin, margin + 80)
private_image = open_png(private)
common_image = open_png(common)
# 徽章为白色胶囊,文字使用场景强调色
assert color_count(private_image, header_box, renderer.BADGE_BG_COLOR) > 200
assert color_count(private_image, header_box, renderer.SCENE_STYLES["private"]["color"]) > 0
assert color_count(common_image, header_box, renderer.SCENE_STYLES["common"]["color"]) > 0
assert color_count(common_image, header_box, renderer.SCENE_STYLES["private"]["color"]) == 0
def test_unknown_scene_falls_back_to_common_style():
module = load_image_renderer()
renderer = build_renderer(module)
assert renderer._scene_style("group")["label"] == "群聊通知"
assert renderer._scene_style("private")["label"] == "私聊通知"
unknown = renderer._scene_style("nope")
assert unknown["label"] == renderer.SCENE_STYLES[renderer.DEFAULT_SCENE]["label"]
assert unknown["color"] == renderer.SCENE_STYLES[renderer.DEFAULT_SCENE]["color"]
assert renderer.render("未知场景", scene="nope") == renderer.render("未知场景", scene="common")
def test_long_text_wraps_without_leaving_the_card():
module = load_image_renderer()
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
content_width = content_right - content_left
lines = renderer._wrap_text("超长文本测试," * 200, content_width)
assert all(renderer._measure_text(line, renderer.font) <= content_width for line in lines)
assert "".join(lines) == "超长文本测试," * 200
image = open_png(renderer.render("超长文本测试," * 200, scene="group"))
width, height = image.size
margin = renderer.OUTER_MARGIN
header_end = header_bottom(image, margin, renderer.BRAND_COLOR)
body_box = (margin, header_end + 1, width - margin, height - margin)
left, top, right, bottom = ink_bbox(image, body_box)
absolute = (body_box[0] + left, body_box[1] + top, body_box[0] + right, body_box[1] + bottom)
assert absolute[0] >= content_left
assert absolute[2] <= content_right
assert absolute[3] < height - margin
def test_explicit_newlines_and_blank_lines_are_preserved():
module = load_image_renderer()
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
lines = renderer._wrap_text("第一行\n第二行\n\n第四行", content_right - content_left)
assert lines == ["第一行", "第二行", "", "第四行"]
def test_cjk_punctuation_does_not_start_or_end_a_line():
module = load_image_renderer()
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
content_width = content_right - content_left
cases = [
# 28 个全角汉字正好铺满一行,逗号会落在下一行行首
"甲" * 28 + ",后续内容" + "乙" * 26 + "(括号内容)",
# 27 个汉字加左括号刚好占满,右括号内容会被挤到下一行,左括号不能留在行尾
"甲" * 27 + "(乙" * 20,
]
for text in cases:
lines = renderer._wrap_text(text, content_width)
assert "".join(lines) == text
for line in lines:
if line:
assert line[0] not in module.ImageRenderer.LINE_START_FORBIDDEN
assert renderer._measure_text(line, renderer.font) <= content_width
for line in lines[:-1]:
assert line[-1] not in module.ImageRenderer.LINE_END_FORBIDDEN
def test_emoji_is_measured_as_square_and_kept_whole():
module = load_image_renderer()
renderer = build_renderer(module)
atoms = list(renderer._iter_atoms("🏇出发", renderer.font))
assert atoms[0][0] == "🏇"
assert atoms[0][1] == renderer.font.size
# ZWJ 组合、肤色修饰、区域指示符组合都不应被拆开
family, flag = "👨‍👩‍👧", "🇨🇳"
atoms = list(renderer._iter_atoms(f"👍🏻{family}{flag}赞", renderer.font))
groups = [atom for atom, _ in atoms]
assert "👍🏻" in groups
assert family in groups
assert flag in groups
def test_emoji_text_does_not_overflow_the_card(monkeypatch):
module = load_image_renderer()
install_offline_pilmoji(monkeypatch, module)
renderer = build_renderer(module)
content_left, content_right = content_limits(renderer)
content_width = content_right - content_left
text = "🏇🎉😀 中文与 Emoji 混排的长通知," * 20
lines = renderer._wrap_text(text, content_width)
assert "".join(lines) == text
assert all(renderer._measure_text(line, renderer.font) <= content_width for line in lines)
image = open_png(renderer.render(text, scene="group"))
width, height = image.size
margin = renderer.OUTER_MARGIN
header_end = header_bottom(image, margin, renderer.BRAND_COLOR)
body_box = (margin, header_end + 1, width - margin, height - margin)
left, _, right, _ = ink_bbox(image, body_box)
assert body_box[0] + left >= content_left
assert body_box[0] + right <= content_right
def test_body_line_height_is_at_least_one_point_six(monkeypatch):
module = load_image_renderer()
renderer = build_renderer(module, line_spacing=1.2)
single = open_png(renderer.render("单行"))
double = open_png(renderer.render("第一行\n第二行"))
line_height = double.size[1] - single.size[1]
assert line_height > 0
assert line_height >= int(renderer._text_size("测试", renderer.font)[1] * 1.6)
def test_default_title_keeps_legacy_text_and_none_hides_it(monkeypatch):
"""默认标题沿用旧版文案(向后兼容),显式 None / 空字符串才隐藏标题行。"""
module = load_image_renderer()
install_offline_pilmoji(monkeypatch, module)
renderer = build_renderer(module)
assert module.ImageRenderer.DEFAULT_TITLE == "蛋定助手通知您:"
default_image = open_png(renderer.render("正文内容"))
hidden_image = open_png(renderer.render("正文内容", title=None))
empty_image = open_png(renderer.render("正文内容", title=""))
custom_image = open_png(renderer.render("正文内容", title="🏇 赛马开赛"))
# 默认渲染比显式隐藏多一行标题
assert default_image.size[1] > hidden_image.size[1]
# 显式 None 与空字符串等效,且自定义单行标题与默认标题高度一致
assert hidden_image.size[1] == empty_image.size[1]
assert custom_image.size[1] == default_image.size[1]
# 默认渲染在头部下方先画标题行,显式隐藏时同一位置直接是正文
margin = renderer.OUTER_MARGIN
header_end = header_bottom(default_image, margin, module.ImageRenderer.BRAND_COLOR)
top_band = (
margin,
header_end + renderer.padding + 1,
default_image.size[0] - margin,
header_end + renderer.padding + 40,
)
default_band = ink_bbox(default_image, top_band)
hidden_band = ink_bbox(hidden_image, top_band)
assert default_band is not None and hidden_band is not None
# 隐藏标题时正文紧贴头部内边距开始
assert first_ink_row(hidden_image, header_end + 1, margin) >= header_end + renderer.padding
# 标题「蛋定助手通知您:」明显宽于正文「正文内容」,据此确认标题行确实绘制了
assert (default_band[2] - default_band[0]) > (hidden_band[2] - hidden_band[0]) * 1.5
# render_to_base64 同样继承默认标题文案
encoded = renderer.render_to_base64("正文内容")
assert open_png(base64.b64decode(encoded[len("base64://"):])).size == default_image.size
def test_empty_text_keeps_placeholder_and_minimum_height():
module = load_image_renderer()
renderer = build_renderer(module)
image = open_png(renderer.render(""))
margin = renderer.OUTER_MARGIN
header_end = header_bottom(image, margin, renderer.BRAND_COLOR)
body_box = (margin, header_end + 1, image.size[0] - margin, image.size[1] - margin)
assert image.size[1] >= module.ImageRenderer.MIN_HEIGHT
assert ink_bbox(image, body_box) is not None
def test_render_falls_back_to_plain_text_when_emoji_source_fails(monkeypatch):
module = load_image_renderer()
renderer = build_renderer(module)
class BrokenPilmoji:
def __init__(self, image):
self.image = image
def __enter__(self):
return self
def __exit__(self, *args):
return False
def text(self, *args, **kwargs):
raise RuntimeError("emoji 源不可用")
monkeypatch.setattr(module, "Pilmoji", BrokenPilmoji)
png = renderer.render("降级测试 🏇", scene="private")
assert png.startswith(b"\x89PNG")
assert renderer._emoji_enabled is False
def test_pure_black_text_color_falls_back_to_soft_ink():
module = load_image_renderer()
assert build_renderer(module, text_color=(0, 0, 0)).text_color == module.ImageRenderer.DEFAULT_TEXT_COLOR
assert build_renderer(module, text_color=(10, 20, 30)).text_color == (10, 20, 30)
assert module.ImageRenderer.DEFAULT_TEXT_COLOR != (0, 0, 0)

View File

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