Files
DanDingNoneBot/tests/test_danding_qqpush_image_render.py

354 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""通知卡片渲染测试:版式、场景徽章、中文/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)