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