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)