feat: 新增通知图片测试命令
This commit is contained in:
353
tests/test_danding_qqpush_image_render.py
Normal file
353
tests/test_danding_qqpush_image_render.py
Normal 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)
|
||||
538
tests/test_danding_qqpush_test_command.py
Normal file
538
tests/test_danding_qqpush_test_command.py
Normal 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"]))
|
||||
Reference in New Issue
Block a user