From a47f003d59dce5bc39f37fe845f5fa3b459e6225 Mon Sep 17 00:00:00 2001 From: Xbaiyz12 <2734320958@qq.com> Date: Mon, 5 Oct 2026 01:05:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E8=80=81=E5=A9=86=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=B6=85=E8=BF=87=205=20=E6=9D=A1=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上百人的群里查看老婆列表会直接刷出一大串文字,把聊天淹没;而官机(QQ 官方机器人) 不支持合并转发,原来「超过阈值走 MessageSegment.node」的做法在官机上等于没生效。 改为:条目超过 WIFE_LIST_IMAGE_THRESHOLD(5) 条时用 PIL 渲染成图片再发,未超过时 仍发纯文本,保留可复制、可搜索的好处。 - 新增 TodayWaifu/list_image.py:条目按从上到下的单列竖排,序号按显示顺序重新编号 (1→N,自上而下递增,与聊天列表读法一致);输出 JPEG 字节,渲染经 run_blocking 下沉到插件线程池。 - 视觉沿用插件内排行榜卡片的规范:背景取鸣潮角色面板图并自上而下渐深压暗(取不到 时退回淡紫渐变底),其上叠半透明白圆角卡片(alpha 112,与排行榜的 105 同量级); 标题与序号徽章用同源的「粉红(255,0,106) → 紫(106,92,255)」渐变,角色名用主题色 加粗突出。 - 卡片是半透明的,浅色背景会吃掉深色小字,故文字一律带一层白色描边,背景明暗都 压得住。 - 排版按 2 倍尺寸绘制再缩回(超采样),圆角、徽章与文字边缘才不显毛刺;条目多时 行距收紧(≥40 条用 46px),避免上百人时图片长得离谱。 - 背景是照片,PNG 无损会到近 MB,故输出 JPEG(质量 88);该图不含透明区域, 转换无损失。 - 图片消息复用既有的 _image_message():base64 编码同样在线程池内完成。 - constants.py 的 LIST_FORWARD_THRESHOLD(10) 由 WIFE_LIST_IMAGE_THRESHOLD(5) 取代, shared.py 的导出同步调整,避免两套阈值并存。 注意 _wife_list_items 返回的 items 首元素是排序用的时间戳(int(updated_at)),并不是 序号:文本版列表一直用 enumerate 重新编号,出图这一支必须照做,否则会把一串十位数字 当成序号画出来。 另外同步 tests/compatibility_manifest.json 里 DailyWifeHelpColumn 的默认值:上游 #41 把该默认值由 4 改成 3 时漏了清单,导致 main 自身的兼容性用例失败(CI 两次红), 这里一并修正。 新增 tests/test_wife_list_image.py(11 项行为测试):0/1/5/6/31 条都能渲染出合法图片、 竖排单列(宽度不随条数变化、高度随条数显著增长)、超长名字不破坏渲染、序号按显示顺序 重新编号(且首元素不影响列宽),以及 _send_wife_list 的分支行为——超过 5 条发图片、 正好 5 条与空列表发文本;并断言旧常量与 MessageSegment.node 已不再出现。测试按 AST 加载渲染模块时同时剥掉 gsuid_core 导入并注入假字体与假资源路径,CI 上无需框架环境, 也不依赖本机角色图。 验证:pytest tests/ -q -> 378 passed(core 根与 CI 同款插件目录路径各跑一次); ruff check -> All checks passed。 --- TodayWaifu/constants.py | 6 +- TodayWaifu/daily.py | 15 +- TodayWaifu/list_image.py | 285 ++++++++++++++++++++++++++++++ TodayWaifu/shared.py | 4 +- tests/compatibility_manifest.json | 2 +- tests/test_wife_list_image.py | 194 ++++++++++++++++++++ 6 files changed, 494 insertions(+), 12 deletions(-) create mode 100644 TodayWaifu/list_image.py create mode 100644 tests/test_wife_list_image.py diff --git a/TodayWaifu/constants.py b/TodayWaifu/constants.py index 00539c6..13c2702 100644 --- a/TodayWaifu/constants.py +++ b/TodayWaifu/constants.py @@ -156,8 +156,10 @@ GALLERY_CACHE_MAX_MB = 512 -# 列表条目超过该数量时改用合并转发发送,避免长消息被平台截断或刷屏。 -LIST_FORWARD_THRESHOLD = 10 +# 列表条目超过该数量时渲染成图片发送:上百人的群直接发一大串文字会淹没聊天, +# 而官机(QQ 官方机器人)不支持合并转发,只能靠出图收敛长度。 +# 条目不超过该数量时仍发纯文本,保留可复制、可搜索的好处。 +WIFE_LIST_IMAGE_THRESHOLD = 5 # 自定义角色的 ID 起始值,取远大于内置角色 ID 的区间,以保证两类 ID 不重叠; diff --git a/TodayWaifu/daily.py b/TodayWaifu/daily.py index d2f3812..70e83bc 100644 --- a/TodayWaifu/daily.py +++ b/TodayWaifu/daily.py @@ -11,12 +11,11 @@ from .shared import ( LOG_PREFIX, - LIST_FORWARD_THRESHOLD, + WIFE_LIST_IMAGE_THRESHOLD, Bot, Event, WifeRecord, RoleCandidate, - MessageSegment, MemberCandidate, RoleRecordValue, re, @@ -33,6 +32,7 @@ _wife_state, wife_list_sv, daily_wife_sv, + _image_message, assign_wife_sv, get_role_quote, _filter_by_mode, @@ -68,6 +68,8 @@ _load_group_display_names, _get_other_daily_wife_name, ) +from .executor import run_blocking +from .list_image import render_wife_list_image def _build_text(role: RoleCandidate, mode: str = 'wife', user_id: str = '') -> str: @@ -658,11 +660,10 @@ async def _send_group_member_wife(bot: Bot, ev: Event) -> list[str] | None: async def _send_wife_list(bot: Bot, ev: Event, mode: str = 'wife') -> None: logger.debug(f'{LOG_PREFIX} 用户 {ev.user_id} 在群 {ev.group_id} 请求了 {mode} 列表') title_text, items = await _wife_list_items(ev, mode) - if len(items) > LIST_FORWARD_THRESHOLD: - await _safe_send( - bot, - MessageSegment.node([_wife_list_text_from_items(title_text, items)]), - ) + # 条目多时渲染成图片:上百人的群直接发一大串文字会淹没聊天,官机又不支持合并转发 + if len(items) > WIFE_LIST_IMAGE_THRESHOLD: + image = await run_blocking(render_wife_list_image, title_text, items) + await _safe_send(bot, await _image_message(image)) return await _safe_send(bot, _wife_list_text_from_items(title_text, items)) diff --git a/TodayWaifu/list_image.py b/TodayWaifu/list_image.py new file mode 100644 index 0000000..73b5c4a --- /dev/null +++ b/TodayWaifu/list_image.py @@ -0,0 +1,285 @@ +"""老婆列表图:条目多时渲染成图片,避免一大串文字刷屏。 + +只在条目超过阈值时启用;条目少时仍发纯文本,保留可复制、可搜索的好处。 +视觉上沿用插件内排行榜卡片的规范:背景图压暗 + 半透明白卡片 + 粉紫渐变点缀。 +条目按从上到下的单列竖排,读起来与聊天里的列表一致。 +""" + +from __future__ import annotations + +import io +import random +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +from gsuid_core.data_store import get_res_path +from gsuid_core.utils.fonts.fonts import core_font + +# 按 2 倍尺寸绘制再缩回:文字与圆角边缘都靠超采样取平滑,直接按目标尺寸画会发毛 +_SCALE = 2 + +_MARGIN = 26 +_CARD_RADIUS = 26 +_TITLE_SIZE = 32 +_TITLE_SUB_SIZE = 18 +_ROW_SIZE = 24 +_INDEX_SIZE = 19 +_COLUMN_GAP = 30 +_INDEX_DIAMETER = 32 +_INDEX_TEXT_GAP = 13 +_ARROW_GAP = 11 +_NAME_ROLE_GAP = 22 +_MIN_CARD_WIDTH = 520 + +# 条目越多行距越紧,避免上百人时图片长得离谱 +_ROW_HEIGHT_ROOMY = 56 +_ROW_HEIGHT_TIGHT = 46 +_TIGHT_FROM = 40 + +# 与排行榜卡片同源的渐变:粉红 → 紫 +_GRADIENT_START = (255, 0, 106) +_GRADIENT_END = (106, 92, 255) + +_CARD_BG = (255, 255, 255, 112) +_CARD_BORDER = (255, 255, 255, 168) +# 卡片是半透明的,浅色背景会吃掉深色小字,故文字一律带一层白色描边 +_TEXT_STROKE = (255, 255, 255, 205) +_STROKE_WIDTH = 3 +_TITLE_SHADOW = (28, 32, 42, 110) +_SUB_FG = (108, 116, 132, 255) +_NAME_FG = (44, 50, 62, 255) +_ROLE_FG = (58, 92, 205, 255) +_ARROW_FG = (150, 158, 175, 235) +_INDEX_FG = (255, 255, 255, 255) +_ROW_DIVIDER = (60, 70, 90, 38) +_FALLBACK_TOP = (236, 240, 252) +_FALLBACK_BOTTOM = (214, 222, 246) + +_IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp') + + +def _role_pile_root() -> Path | None: + root = get_res_path('XutheringWavesUID') / 'custom_role_pile' + return root if root.is_dir() else None + + +def _cover_crop(image: Image.Image, width: int, height: int) -> Image.Image: + scale = max(width / image.width, height / image.height) + resized = image.resize( + (max(1, int(image.width * scale)), max(1, int(image.height * scale))), + Image.LANCZOS, + ) + left = (resized.width - width) // 2 + top = (resized.height - height) // 2 + return resized.crop((left, top, left + width, top + height)) + + +def _background(width: int, height: int) -> Image.Image: + """背景优先用鸣潮面板图,取不到时退回淡紫渐变底。""" + root = _role_pile_root() + if root is not None: + dirs = [entry for entry in root.iterdir() if entry.is_dir()] + for _ in range(4): + if not dirs: + break + chosen = random.choice(dirs) + dirs.remove(chosen) + images = [p for p in chosen.iterdir() if p.suffix.lower() in _IMAGE_SUFFIXES] + if not images: + continue + try: + with Image.open(random.choice(images)) as raw: + return _cover_crop(raw.convert('RGBA'), width, height) + except OSError: + continue + + strip = Image.new('RGBA', (1, height)) + pixels = strip.load() + if pixels is not None: + for y in range(height): + ratio = y / max(1, height - 1) + pixels[0, y] = ( + int(_FALLBACK_TOP[0] + (_FALLBACK_BOTTOM[0] - _FALLBACK_TOP[0]) * ratio), + int(_FALLBACK_TOP[1] + (_FALLBACK_BOTTOM[1] - _FALLBACK_TOP[1]) * ratio), + int(_FALLBACK_TOP[2] + (_FALLBACK_BOTTOM[2] - _FALLBACK_TOP[2]) * ratio), + 255, + ) + return strip.resize((width, height), Image.NEAREST) + + +def _darken(width: int, height: int) -> Image.Image: + """自上而下加深的压暗层,让白卡片与浅色文字都压得住。""" + strip = Image.new('RGBA', (1, height)) + pixels = strip.load() + if pixels is not None: + for y in range(height): + ratio = y / max(1, height - 1) + pixels[0, y] = (14, 18, 28, int(96 + 76 * ratio)) + return strip.resize((width, height), Image.NEAREST) + + +def _gradient_image(size: tuple[int, int], start: tuple[int, int, int], end: tuple[int, int, int]) -> Image.Image: + """生成水平渐变色块,供文字与徽章取色用。""" + width, height = size + strip = Image.new('RGBA', (width, 1)) + pixels = strip.load() + if pixels is not None: + for x in range(width): + ratio = x / max(1, width - 1) + pixels[x, 0] = ( + int(start[0] + (end[0] - start[0]) * ratio), + int(start[1] + (end[1] - start[1]) * ratio), + int(start[2] + (end[2] - start[2]) * ratio), + 255, + ) + return strip.resize((width, height), Image.NEAREST) + + +def _gradient_text( + text: str, + font: ImageFont.FreeTypeFont, + start: tuple[int, int, int], + end: tuple[int, int, int], +) -> Image.Image: + """水平渐变色文字,用作标题。""" + probe = ImageDraw.Draw(Image.new('RGBA', (1, 1))) + bbox = probe.textbbox((0, 0), text, font=font) + width = max(1, int(bbox[2] - bbox[0]) + 12) + height = max(1, int(bbox[3] - bbox[1]) + 12) + mask = Image.new('L', (width, height), 0) + ImageDraw.Draw(mask).text((-bbox[0] + 6, -bbox[1] + 6), text, font=font, fill=255) + result = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + result.paste(_gradient_image((width, height), start, end), (0, 0), mask) + return result + + +def _gradient_badge(diameter: int) -> Image.Image: + """渐变圆形徽章底,用作序号。""" + badge = _gradient_image((diameter, diameter), _GRADIENT_START, _GRADIENT_END) + mask = Image.new('L', (diameter, diameter), 0) + ImageDraw.Draw(mask).ellipse((0, 0, diameter - 1, diameter - 1), fill=255) + out = Image.new('RGBA', (diameter, diameter), (0, 0, 0, 0)) + out.paste(badge, (0, 0), mask) + return out + + +def render_wife_list_image(title_text: str, items: list[tuple[int, str, str]]) -> bytes: + """把老婆列表渲染成 PNG 字节;items 为空时只画标题栏。""" + s = _SCALE + title_font = core_font(_TITLE_SIZE * s) + sub_font = core_font(_TITLE_SUB_SIZE * s, weight=400) + name_font = core_font(_ROW_SIZE * s) + role_font = core_font(_ROW_SIZE * s, weight=630) + index_font = core_font(_INDEX_SIZE * s, weight=630) + + probe = ImageDraw.Draw(Image.new('RGB', (8, 8))) + + def width_of(text: str, font: object) -> int: + return int(probe.textlength(text, font=font)) + + row_height = (_ROW_HEIGHT_TIGHT if len(items) >= _TIGHT_FROM else _ROW_HEIGHT_ROOMY) * s + index_d = _INDEX_DIAMETER * s + arrow_w = width_of('→', name_font) + + # 单列竖排:序号自上而下递增,与聊天里的列表读法一致 + widest = 0 + for _, display_name, wife_name in items: + row_w = index_d + width_of(display_name, name_font) + arrow_w + width_of(wife_name, role_font) + widest = max(widest, row_w) + row_w = widest + (_INDEX_TEXT_GAP + _NAME_ROLE_GAP + _ARROW_GAP) * s + 20 * s + + sub_text = f'共 {len(items)} 位' if items else '暂无记录' + title_image = _gradient_text(title_text, title_font, _GRADIENT_START, _GRADIENT_END) + + card_w = max(_MIN_CARD_WIDTH * s, row_w, title_image.width + 60 * s) + 34 * s * 2 + card_h = 22 * s + _TITLE_SIZE * s + 22 * s + (len(items) * row_height + 16 * s if items else 0) + width = card_w + _MARGIN * s * 2 + height = card_h + _MARGIN * s * 2 + + canvas = _background(width, height) + canvas = Image.alpha_composite(canvas, _darken(width, height)) + + card = Image.new('RGBA', (card_w, card_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(card) + draw.rounded_rectangle( + (0, 0, card_w - 1, card_h - 1), + radius=_CARD_RADIUS * s, + fill=_CARD_BG, + outline=_CARD_BORDER, + width=max(1, s * 2), + ) + + left = 34 * s + top = 22 * s + title_x = max(left, (card_w - title_image.width) // 2) + draw.text((title_x + 2 * s, top + 2 * s), title_text, font=title_font, fill=_TITLE_SHADOW) + card.paste(title_image, (title_x, top), title_image) + draw.text( + (card_w - left - width_of(sub_text, sub_font), top + 10 * s), + sub_text, + font=sub_font, + fill=_SUB_FG, + stroke_width=_STROKE_WIDTH, + stroke_fill=_TEXT_STROKE, + ) + + if items: + badge = _gradient_badge(index_d) + body_top = top + _TITLE_SIZE * s + 22 * s + # items 的首元素是排序用的时间戳,序号必须按显示顺序重新编号 + for position, (_, display_name, wife_name) in enumerate(items, 1): + row = position - 1 + center_y = body_top + row * row_height + row_height // 2 + if row: + divider_y = center_y - row_height // 2 + draw.line((left, divider_y, card_w - left, divider_y), fill=_ROW_DIVIDER, width=max(1, s)) + + badge_top = center_y - index_d // 2 + card.paste(badge, (left, badge_top), badge) + draw.text( + (left + index_d // 2, center_y), + str(position), + font=index_font, + fill=_INDEX_FG, + anchor='mm', + ) + + cursor = left + index_d + _INDEX_TEXT_GAP * s + draw.text( + (cursor, center_y), + display_name, + font=name_font, + fill=_NAME_FG, + anchor='lm', + stroke_width=_STROKE_WIDTH, + stroke_fill=_TEXT_STROKE, + ) + cursor += width_of(display_name, name_font) + _NAME_ROLE_GAP * s + draw.text( + (cursor, center_y), + '→', + font=name_font, + fill=_ARROW_FG, + anchor='lm', + stroke_width=_STROKE_WIDTH, + stroke_fill=_TEXT_STROKE, + ) + cursor += arrow_w + _ARROW_GAP * s + draw.text( + (cursor, center_y), + wife_name, + font=role_font, + fill=_ROLE_FG, + anchor='lm', + stroke_width=_STROKE_WIDTH, + stroke_fill=_TEXT_STROKE, + ) + + canvas.alpha_composite(card, (_MARGIN * s, _MARGIN * s)) + buffer = io.BytesIO() + # 背景是照片,PNG 无损会到近 MB;JPEG 体积小得多且此处不含透明区域 + canvas.convert('RGB').resize((width // s, height // s), Image.LANCZOS).save( + buffer, format='JPEG', quality=88, optimize=True + ) + return buffer.getvalue() diff --git a/TodayWaifu/shared.py b/TodayWaifu/shared.py index 2b89111..a7bdbbb 100644 --- a/TodayWaifu/shared.py +++ b/TodayWaifu/shared.py @@ -160,7 +160,6 @@ CUSTOM_ROLE_ID_START, GALLERY_CACHE_MAX_MB, EXCLUDED_ROLE_KEYWORDS, - LIST_FORWARD_THRESHOLD, UPLOAD_IMAGE_MAX_BYTES, DEFAULT_GALLERY_API_URL, CIRCUIT_COOLDOWN_SECONDS, @@ -168,6 +167,7 @@ LOLI_DOWNLOAD_LOG_PREFIX, MAX_IMAGE_RESPONSE_BYTES, CIRCUIT_FAILURE_THRESHOLD, + WIFE_LIST_IMAGE_THRESHOLD, IMAGE_HTTP_TIMEOUT_SECONDS, MAX_GALLERY_RESPONSE_BYTES, DAILY_RECORD_RETENTION_DAYS, @@ -261,7 +261,7 @@ 'DEFAULT_GALLERY_API_URL', 'DEFAULT_GALLERY_BASE_URL', 'DailyKindMetadata', 'DailyWifeConfig', 'DailyContext', 'EXCLUDED_ROLE_KEYWORDS', 'EXCLUDED_ROLE_NAMES', 'Event', 'HELP_ICON_PATH', - 'HTTPError', 'IMAGE_EXTENSIONS', 'LIST_FORWARD_THRESHOLD', 'LOG_PREFIX', + 'HTTPError', 'IMAGE_EXTENSIONS', 'WIFE_LIST_IMAGE_THRESHOLD', 'LOG_PREFIX', 'LOLI_DOWNLOAD_LOG_PREFIX', 'LOLI_IMAGE_DIR_NAME', 'LOLI_MOBILE_UA', 'LOLICONAPP_API_URL', 'LOLICONAPP_TAGS', 'MemberCandidate', 'Message', 'MessageSegment', 'Path', diff --git a/tests/compatibility_manifest.json b/tests/compatibility_manifest.json index bc80f45..33567f9 100644 --- a/tests/compatibility_manifest.json +++ b/tests/compatibility_manifest.json @@ -330,7 +330,7 @@ }, { "key": "DailyWifeHelpColumn", - "expr": "GsIntConfig('帮助图宽度(每行列数)', '控制帮助图一行放几条命令:4 更紧凑,3 留白更多。数值越大画布越宽;上限 5,超过会溢出画布', 4, 5)" + "expr": "GsIntConfig('帮助图宽度(每行列数)', '控制帮助图一行放几条命令:4 更紧凑,3 留白更多。数值越大画布越宽;上限 5,超过会溢出画布', 3, 5)" } ] }, diff --git a/tests/test_wife_list_image.py b/tests/test_wife_list_image.py new file mode 100644 index 0000000..60ae924 --- /dev/null +++ b/tests/test_wife_list_image.py @@ -0,0 +1,194 @@ +"""老婆列表:条目超过阈值时渲染成图片,不超过时保持纯文本。 + +测试不依赖框架环境:渲染模块直接用 PIL 跑真实渲染并校验 PNG 头;_send_wife_list 用 +AST 提取 + 注入 fake 依赖,验证「超过阈值发图片、未超过发文本」这一行为分支。 +""" + +import io +import ast +import asyncio +import tempfile +import unittest +from typing import Any +from pathlib import Path + +from PIL import Image, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / 'TodayWaifu' +LIST_IMAGE_PATH = PACKAGE / 'list_image.py' +DAILY_PATH = PACKAGE / 'daily.py' +CONSTANTS_PATH = PACKAGE / 'constants.py' + +JPEG_MAGIC = b'\xff\xd8\xff' + + +def _fake_font(size: int = 24, weight: float = 630) -> ImageFont.ImageFont: + """替代框架的 core_font:测试只关心能否出图,不关心字体。""" + return ImageFont.load_default() + + +def _load_list_image_module() -> dict[str, Any]: + """按 AST 加载 list_image.py,剥掉框架导入并注入假依赖(CI 里没有 gsuid_core)。""" + tree = ast.parse(LIST_IMAGE_PATH.read_text(encoding='utf-8-sig')) + tree.body = [ + node + for node in tree.body + if not ( + isinstance(node, ast.ImportFrom) + and (node.level == 1 or (node.module or '').startswith('gsuid_core')) + ) + ] + globals_dict: dict[str, Any] = { + 'core_font': _fake_font, + # 指向不存在的目录,渲染于是走渐变兜底,测试不依赖本机角色图 + 'get_res_path': lambda name='': Path(tempfile.gettempdir()) / '__no_role_pile__', + } + exec(compile(tree, str(LIST_IMAGE_PATH), 'exec'), globals_dict) + return globals_dict + + +def _constant(name: str) -> Any: + tree = ast.parse(CONSTANTS_PATH.read_text(encoding='utf-8-sig')) + for node in tree.body: + if not isinstance(node, ast.Assign) or not isinstance(node.targets[0], ast.Name): + continue + if node.targets[0].id != name: + continue + return eval(compile(ast.Expression(node.value), '', 'eval'), {'__builtins__': {}}, {}) + raise AssertionError(f'常量 {name} 不存在') + + +def _extract_send_wife_list(items: list[tuple[int, str, str]], sent: list[Any]) -> Any: + """提取 _send_wife_list,注入 fake 依赖后返回协程函数。""" + tree = ast.parse(DAILY_PATH.read_text(encoding='utf-8-sig')) + function = next( + node + for node in tree.body + if isinstance(node, ast.AsyncFunctionDef) and node.name == '_send_wife_list' + ) + future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0) + module = ast.Module(body=[future, function], type_ignores=[]) + ast.fix_missing_locations(module) + + async def fake_items(ev: Any, mode: str) -> tuple[str, list[tuple[int, str, str]]]: + return '今日老婆列表:', items + + async def fake_safe_send(bot: Any, message: Any) -> None: + sent.append(message) + + async def fake_run_blocking(func: Any, *args: Any) -> Any: + return func(*args) + + async def fake_image_message(data: bytes) -> str: + return f'IMAGE:{len(data)}' + + globals_dict: dict[str, Any] = { + 'WIFE_LIST_IMAGE_THRESHOLD': _constant('WIFE_LIST_IMAGE_THRESHOLD'), + 'LOG_PREFIX': '[测试]', + 'logger': type('L', (), {'debug': staticmethod(lambda *a, **k: None)})(), + '_wife_list_items': fake_items, + '_wife_list_text_from_items': lambda title, rows: f'TEXT:{len(rows)}', + '_safe_send': fake_safe_send, + 'run_blocking': fake_run_blocking, + 'render_wife_list_image': lambda title, rows: JPEG_MAGIC + b'fake', + '_image_message': fake_image_message, + } + exec(compile(module, str(DAILY_PATH), 'exec'), globals_dict) + return globals_dict['_send_wife_list'] + + +class RenderWifeListImageTests(unittest.TestCase): + def setUp(self) -> None: + self.mod = _load_list_image_module() + self.render = self.mod['render_wife_list_image'] + + def test_renders_png_for_various_sizes(self) -> None: + for count in (0, 1, 5, 6, 31): + with self.subTest(count=count): + items = [(i, f'群友{i}', f'角色{i}') for i in range(1, count + 1)] + data = self.render('今日老婆列表:', items) + self.assertTrue(data.startswith(JPEG_MAGIC), '必须输出合法图片') + # 空列表只有标题,画布本就很小 + if count: + self.assertGreater(len(data), 1000) + + def test_long_names_do_not_break_rendering(self) -> None: + items = [(1, '很长的群昵称' * 6, '今汐'), (2, '乙', '名字很长的角色' * 4)] + self.assertTrue(self.render('今日老婆列表:', items).startswith(JPEG_MAGIC)) + + def test_rows_are_single_column(self) -> None: + """竖排单列:条目越多图越高、宽度基本不变。""" + small = self.render('今日老婆列表:', [(i, f'群友{i}', f'角色{i}') for i in range(1, 4)]) + large = self.render('今日老婆列表:', [(i, f'群友{i}', f'角色{i}') for i in range(1, 40)]) + small_box = Image.open(io.BytesIO(small)).size + large_box = Image.open(io.BytesIO(large)).size + self.assertEqual(small_box[0], large_box[0], '单列时宽度不应随条数变化') + self.assertGreater(large_box[1], small_box[1] * 5, '高度应随条数显著增长') + + def test_index_column_is_not_drawn_from_item_key(self) -> None: + """序号必须按显示顺序重新编号。 + + items 的首元素是排序用的时间戳,直接画出来会变成一串长数字。 + """ + src = LIST_IMAGE_PATH.read_text(encoding='utf-8') + self.assertIn('enumerate(items, 1)', src) + self.assertNotIn('str(index)', src) + + def test_long_item_key_does_not_widen_row(self) -> None: + """首元素(时间戳)不参与排版,故不影响列宽。""" + short_key = self.render('今日老婆列表:', [(1, '甲', '乙')]) + long_key = self.render('今日老婆列表:', [(1789001234, '甲', '乙')]) + self.assertEqual( + Image.open(io.BytesIO(short_key)).size, + Image.open(io.BytesIO(long_key)).size, + ) + + +class _FakeEvent: + user_id = '123456' + group_id = '654321' + + +class SendWifeListBranchTests(unittest.TestCase): + def test_above_threshold_sends_image(self) -> None: + sent: list[Any] = [] + items = [(i, f'群友{i}', f'角色{i}') for i in range(1, 7)] + send = _extract_send_wife_list(items, sent) + asyncio.run(send(object(), _FakeEvent())) + self.assertEqual(len(sent), 1) + self.assertTrue(str(sent[0]).startswith('IMAGE:'), f'应为图片,实际 {sent[0]}') + + def test_at_threshold_sends_text(self) -> None: + sent: list[Any] = [] + items = [(i, f'群友{i}', f'角色{i}') for i in range(1, 6)] + send = _extract_send_wife_list(items, sent) + asyncio.run(send(object(), _FakeEvent())) + self.assertEqual(len(sent), 1) + self.assertEqual(str(sent[0]), 'TEXT:5') + + def test_empty_list_sends_text(self) -> None: + sent: list[Any] = [] + send = _extract_send_wife_list([], sent) + asyncio.run(send(object(), _FakeEvent())) + self.assertEqual(str(sent[0]), 'TEXT:0') + + +class ThresholdConstantTests(unittest.TestCase): + def test_threshold_is_five(self) -> None: + self.assertEqual(_constant('WIFE_LIST_IMAGE_THRESHOLD'), 5) + + def test_old_forward_constant_is_gone(self) -> None: + """旧常量已由渲染阈值取代,避免两套阈值并存。""" + source = CONSTANTS_PATH.read_text(encoding='utf-8') + self.assertNotIn('LIST_FORWARD_THRESHOLD', source) + + def test_daily_uses_image_threshold(self) -> None: + source = DAILY_PATH.read_text(encoding='utf-8') + self.assertIn('WIFE_LIST_IMAGE_THRESHOLD', source) + self.assertIn('render_wife_list_image', source) + self.assertNotIn('MessageSegment.node', source) + + +if __name__ == '__main__': + unittest.main() From 1c5b144f8aa42e3148f082931fcd10e5c9069c41 Mon Sep 17 00:00:00 2001 From: Mimo <278909251+MimoKit@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:08:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A5=20=E7=A7=BB=E9=99=A4=E8=80=81?= =?UTF-8?q?=E5=A9=86=E5=88=97=E8=A1=A8=E8=BD=AC=E5=9B=BE=E7=89=87=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0,=20=E5=8F=AA=E4=BF=9D=E7=95=99=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7=E6=B8=85=E5=8D=95=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 撤回 list_image.py 与 tests/test_wife_list_image.py - daily.py / constants.py / shared.py 回到 main 的合并转发逻辑, 不保留两套阈值 - 保留 tests/compatibility_manifest.json 的同步: DailyWifeHelpColumn 默认值 4 -> 3 - 转图片后续改用其他方式实现 --- TodayWaifu/constants.py | 6 +- TodayWaifu/daily.py | 15 +- TodayWaifu/list_image.py | 285 ---------------------------------- TodayWaifu/shared.py | 4 +- tests/test_wife_list_image.py | 194 ----------------------- 5 files changed, 11 insertions(+), 493 deletions(-) delete mode 100644 TodayWaifu/list_image.py delete mode 100644 tests/test_wife_list_image.py diff --git a/TodayWaifu/constants.py b/TodayWaifu/constants.py index 13c2702..00539c6 100644 --- a/TodayWaifu/constants.py +++ b/TodayWaifu/constants.py @@ -156,10 +156,8 @@ GALLERY_CACHE_MAX_MB = 512 -# 列表条目超过该数量时渲染成图片发送:上百人的群直接发一大串文字会淹没聊天, -# 而官机(QQ 官方机器人)不支持合并转发,只能靠出图收敛长度。 -# 条目不超过该数量时仍发纯文本,保留可复制、可搜索的好处。 -WIFE_LIST_IMAGE_THRESHOLD = 5 +# 列表条目超过该数量时改用合并转发发送,避免长消息被平台截断或刷屏。 +LIST_FORWARD_THRESHOLD = 10 # 自定义角色的 ID 起始值,取远大于内置角色 ID 的区间,以保证两类 ID 不重叠; diff --git a/TodayWaifu/daily.py b/TodayWaifu/daily.py index 70e83bc..d2f3812 100644 --- a/TodayWaifu/daily.py +++ b/TodayWaifu/daily.py @@ -11,11 +11,12 @@ from .shared import ( LOG_PREFIX, - WIFE_LIST_IMAGE_THRESHOLD, + LIST_FORWARD_THRESHOLD, Bot, Event, WifeRecord, RoleCandidate, + MessageSegment, MemberCandidate, RoleRecordValue, re, @@ -32,7 +33,6 @@ _wife_state, wife_list_sv, daily_wife_sv, - _image_message, assign_wife_sv, get_role_quote, _filter_by_mode, @@ -68,8 +68,6 @@ _load_group_display_names, _get_other_daily_wife_name, ) -from .executor import run_blocking -from .list_image import render_wife_list_image def _build_text(role: RoleCandidate, mode: str = 'wife', user_id: str = '') -> str: @@ -660,10 +658,11 @@ async def _send_group_member_wife(bot: Bot, ev: Event) -> list[str] | None: async def _send_wife_list(bot: Bot, ev: Event, mode: str = 'wife') -> None: logger.debug(f'{LOG_PREFIX} 用户 {ev.user_id} 在群 {ev.group_id} 请求了 {mode} 列表') title_text, items = await _wife_list_items(ev, mode) - # 条目多时渲染成图片:上百人的群直接发一大串文字会淹没聊天,官机又不支持合并转发 - if len(items) > WIFE_LIST_IMAGE_THRESHOLD: - image = await run_blocking(render_wife_list_image, title_text, items) - await _safe_send(bot, await _image_message(image)) + if len(items) > LIST_FORWARD_THRESHOLD: + await _safe_send( + bot, + MessageSegment.node([_wife_list_text_from_items(title_text, items)]), + ) return await _safe_send(bot, _wife_list_text_from_items(title_text, items)) diff --git a/TodayWaifu/list_image.py b/TodayWaifu/list_image.py deleted file mode 100644 index 73b5c4a..0000000 --- a/TodayWaifu/list_image.py +++ /dev/null @@ -1,285 +0,0 @@ -"""老婆列表图:条目多时渲染成图片,避免一大串文字刷屏。 - -只在条目超过阈值时启用;条目少时仍发纯文本,保留可复制、可搜索的好处。 -视觉上沿用插件内排行榜卡片的规范:背景图压暗 + 半透明白卡片 + 粉紫渐变点缀。 -条目按从上到下的单列竖排,读起来与聊天里的列表一致。 -""" - -from __future__ import annotations - -import io -import random -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFont - -from gsuid_core.data_store import get_res_path -from gsuid_core.utils.fonts.fonts import core_font - -# 按 2 倍尺寸绘制再缩回:文字与圆角边缘都靠超采样取平滑,直接按目标尺寸画会发毛 -_SCALE = 2 - -_MARGIN = 26 -_CARD_RADIUS = 26 -_TITLE_SIZE = 32 -_TITLE_SUB_SIZE = 18 -_ROW_SIZE = 24 -_INDEX_SIZE = 19 -_COLUMN_GAP = 30 -_INDEX_DIAMETER = 32 -_INDEX_TEXT_GAP = 13 -_ARROW_GAP = 11 -_NAME_ROLE_GAP = 22 -_MIN_CARD_WIDTH = 520 - -# 条目越多行距越紧,避免上百人时图片长得离谱 -_ROW_HEIGHT_ROOMY = 56 -_ROW_HEIGHT_TIGHT = 46 -_TIGHT_FROM = 40 - -# 与排行榜卡片同源的渐变:粉红 → 紫 -_GRADIENT_START = (255, 0, 106) -_GRADIENT_END = (106, 92, 255) - -_CARD_BG = (255, 255, 255, 112) -_CARD_BORDER = (255, 255, 255, 168) -# 卡片是半透明的,浅色背景会吃掉深色小字,故文字一律带一层白色描边 -_TEXT_STROKE = (255, 255, 255, 205) -_STROKE_WIDTH = 3 -_TITLE_SHADOW = (28, 32, 42, 110) -_SUB_FG = (108, 116, 132, 255) -_NAME_FG = (44, 50, 62, 255) -_ROLE_FG = (58, 92, 205, 255) -_ARROW_FG = (150, 158, 175, 235) -_INDEX_FG = (255, 255, 255, 255) -_ROW_DIVIDER = (60, 70, 90, 38) -_FALLBACK_TOP = (236, 240, 252) -_FALLBACK_BOTTOM = (214, 222, 246) - -_IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp') - - -def _role_pile_root() -> Path | None: - root = get_res_path('XutheringWavesUID') / 'custom_role_pile' - return root if root.is_dir() else None - - -def _cover_crop(image: Image.Image, width: int, height: int) -> Image.Image: - scale = max(width / image.width, height / image.height) - resized = image.resize( - (max(1, int(image.width * scale)), max(1, int(image.height * scale))), - Image.LANCZOS, - ) - left = (resized.width - width) // 2 - top = (resized.height - height) // 2 - return resized.crop((left, top, left + width, top + height)) - - -def _background(width: int, height: int) -> Image.Image: - """背景优先用鸣潮面板图,取不到时退回淡紫渐变底。""" - root = _role_pile_root() - if root is not None: - dirs = [entry for entry in root.iterdir() if entry.is_dir()] - for _ in range(4): - if not dirs: - break - chosen = random.choice(dirs) - dirs.remove(chosen) - images = [p for p in chosen.iterdir() if p.suffix.lower() in _IMAGE_SUFFIXES] - if not images: - continue - try: - with Image.open(random.choice(images)) as raw: - return _cover_crop(raw.convert('RGBA'), width, height) - except OSError: - continue - - strip = Image.new('RGBA', (1, height)) - pixels = strip.load() - if pixels is not None: - for y in range(height): - ratio = y / max(1, height - 1) - pixels[0, y] = ( - int(_FALLBACK_TOP[0] + (_FALLBACK_BOTTOM[0] - _FALLBACK_TOP[0]) * ratio), - int(_FALLBACK_TOP[1] + (_FALLBACK_BOTTOM[1] - _FALLBACK_TOP[1]) * ratio), - int(_FALLBACK_TOP[2] + (_FALLBACK_BOTTOM[2] - _FALLBACK_TOP[2]) * ratio), - 255, - ) - return strip.resize((width, height), Image.NEAREST) - - -def _darken(width: int, height: int) -> Image.Image: - """自上而下加深的压暗层,让白卡片与浅色文字都压得住。""" - strip = Image.new('RGBA', (1, height)) - pixels = strip.load() - if pixels is not None: - for y in range(height): - ratio = y / max(1, height - 1) - pixels[0, y] = (14, 18, 28, int(96 + 76 * ratio)) - return strip.resize((width, height), Image.NEAREST) - - -def _gradient_image(size: tuple[int, int], start: tuple[int, int, int], end: tuple[int, int, int]) -> Image.Image: - """生成水平渐变色块,供文字与徽章取色用。""" - width, height = size - strip = Image.new('RGBA', (width, 1)) - pixels = strip.load() - if pixels is not None: - for x in range(width): - ratio = x / max(1, width - 1) - pixels[x, 0] = ( - int(start[0] + (end[0] - start[0]) * ratio), - int(start[1] + (end[1] - start[1]) * ratio), - int(start[2] + (end[2] - start[2]) * ratio), - 255, - ) - return strip.resize((width, height), Image.NEAREST) - - -def _gradient_text( - text: str, - font: ImageFont.FreeTypeFont, - start: tuple[int, int, int], - end: tuple[int, int, int], -) -> Image.Image: - """水平渐变色文字,用作标题。""" - probe = ImageDraw.Draw(Image.new('RGBA', (1, 1))) - bbox = probe.textbbox((0, 0), text, font=font) - width = max(1, int(bbox[2] - bbox[0]) + 12) - height = max(1, int(bbox[3] - bbox[1]) + 12) - mask = Image.new('L', (width, height), 0) - ImageDraw.Draw(mask).text((-bbox[0] + 6, -bbox[1] + 6), text, font=font, fill=255) - result = Image.new('RGBA', (width, height), (0, 0, 0, 0)) - result.paste(_gradient_image((width, height), start, end), (0, 0), mask) - return result - - -def _gradient_badge(diameter: int) -> Image.Image: - """渐变圆形徽章底,用作序号。""" - badge = _gradient_image((diameter, diameter), _GRADIENT_START, _GRADIENT_END) - mask = Image.new('L', (diameter, diameter), 0) - ImageDraw.Draw(mask).ellipse((0, 0, diameter - 1, diameter - 1), fill=255) - out = Image.new('RGBA', (diameter, diameter), (0, 0, 0, 0)) - out.paste(badge, (0, 0), mask) - return out - - -def render_wife_list_image(title_text: str, items: list[tuple[int, str, str]]) -> bytes: - """把老婆列表渲染成 PNG 字节;items 为空时只画标题栏。""" - s = _SCALE - title_font = core_font(_TITLE_SIZE * s) - sub_font = core_font(_TITLE_SUB_SIZE * s, weight=400) - name_font = core_font(_ROW_SIZE * s) - role_font = core_font(_ROW_SIZE * s, weight=630) - index_font = core_font(_INDEX_SIZE * s, weight=630) - - probe = ImageDraw.Draw(Image.new('RGB', (8, 8))) - - def width_of(text: str, font: object) -> int: - return int(probe.textlength(text, font=font)) - - row_height = (_ROW_HEIGHT_TIGHT if len(items) >= _TIGHT_FROM else _ROW_HEIGHT_ROOMY) * s - index_d = _INDEX_DIAMETER * s - arrow_w = width_of('→', name_font) - - # 单列竖排:序号自上而下递增,与聊天里的列表读法一致 - widest = 0 - for _, display_name, wife_name in items: - row_w = index_d + width_of(display_name, name_font) + arrow_w + width_of(wife_name, role_font) - widest = max(widest, row_w) - row_w = widest + (_INDEX_TEXT_GAP + _NAME_ROLE_GAP + _ARROW_GAP) * s + 20 * s - - sub_text = f'共 {len(items)} 位' if items else '暂无记录' - title_image = _gradient_text(title_text, title_font, _GRADIENT_START, _GRADIENT_END) - - card_w = max(_MIN_CARD_WIDTH * s, row_w, title_image.width + 60 * s) + 34 * s * 2 - card_h = 22 * s + _TITLE_SIZE * s + 22 * s + (len(items) * row_height + 16 * s if items else 0) - width = card_w + _MARGIN * s * 2 - height = card_h + _MARGIN * s * 2 - - canvas = _background(width, height) - canvas = Image.alpha_composite(canvas, _darken(width, height)) - - card = Image.new('RGBA', (card_w, card_h), (0, 0, 0, 0)) - draw = ImageDraw.Draw(card) - draw.rounded_rectangle( - (0, 0, card_w - 1, card_h - 1), - radius=_CARD_RADIUS * s, - fill=_CARD_BG, - outline=_CARD_BORDER, - width=max(1, s * 2), - ) - - left = 34 * s - top = 22 * s - title_x = max(left, (card_w - title_image.width) // 2) - draw.text((title_x + 2 * s, top + 2 * s), title_text, font=title_font, fill=_TITLE_SHADOW) - card.paste(title_image, (title_x, top), title_image) - draw.text( - (card_w - left - width_of(sub_text, sub_font), top + 10 * s), - sub_text, - font=sub_font, - fill=_SUB_FG, - stroke_width=_STROKE_WIDTH, - stroke_fill=_TEXT_STROKE, - ) - - if items: - badge = _gradient_badge(index_d) - body_top = top + _TITLE_SIZE * s + 22 * s - # items 的首元素是排序用的时间戳,序号必须按显示顺序重新编号 - for position, (_, display_name, wife_name) in enumerate(items, 1): - row = position - 1 - center_y = body_top + row * row_height + row_height // 2 - if row: - divider_y = center_y - row_height // 2 - draw.line((left, divider_y, card_w - left, divider_y), fill=_ROW_DIVIDER, width=max(1, s)) - - badge_top = center_y - index_d // 2 - card.paste(badge, (left, badge_top), badge) - draw.text( - (left + index_d // 2, center_y), - str(position), - font=index_font, - fill=_INDEX_FG, - anchor='mm', - ) - - cursor = left + index_d + _INDEX_TEXT_GAP * s - draw.text( - (cursor, center_y), - display_name, - font=name_font, - fill=_NAME_FG, - anchor='lm', - stroke_width=_STROKE_WIDTH, - stroke_fill=_TEXT_STROKE, - ) - cursor += width_of(display_name, name_font) + _NAME_ROLE_GAP * s - draw.text( - (cursor, center_y), - '→', - font=name_font, - fill=_ARROW_FG, - anchor='lm', - stroke_width=_STROKE_WIDTH, - stroke_fill=_TEXT_STROKE, - ) - cursor += arrow_w + _ARROW_GAP * s - draw.text( - (cursor, center_y), - wife_name, - font=role_font, - fill=_ROLE_FG, - anchor='lm', - stroke_width=_STROKE_WIDTH, - stroke_fill=_TEXT_STROKE, - ) - - canvas.alpha_composite(card, (_MARGIN * s, _MARGIN * s)) - buffer = io.BytesIO() - # 背景是照片,PNG 无损会到近 MB;JPEG 体积小得多且此处不含透明区域 - canvas.convert('RGB').resize((width // s, height // s), Image.LANCZOS).save( - buffer, format='JPEG', quality=88, optimize=True - ) - return buffer.getvalue() diff --git a/TodayWaifu/shared.py b/TodayWaifu/shared.py index a7bdbbb..2b89111 100644 --- a/TodayWaifu/shared.py +++ b/TodayWaifu/shared.py @@ -160,6 +160,7 @@ CUSTOM_ROLE_ID_START, GALLERY_CACHE_MAX_MB, EXCLUDED_ROLE_KEYWORDS, + LIST_FORWARD_THRESHOLD, UPLOAD_IMAGE_MAX_BYTES, DEFAULT_GALLERY_API_URL, CIRCUIT_COOLDOWN_SECONDS, @@ -167,7 +168,6 @@ LOLI_DOWNLOAD_LOG_PREFIX, MAX_IMAGE_RESPONSE_BYTES, CIRCUIT_FAILURE_THRESHOLD, - WIFE_LIST_IMAGE_THRESHOLD, IMAGE_HTTP_TIMEOUT_SECONDS, MAX_GALLERY_RESPONSE_BYTES, DAILY_RECORD_RETENTION_DAYS, @@ -261,7 +261,7 @@ 'DEFAULT_GALLERY_API_URL', 'DEFAULT_GALLERY_BASE_URL', 'DailyKindMetadata', 'DailyWifeConfig', 'DailyContext', 'EXCLUDED_ROLE_KEYWORDS', 'EXCLUDED_ROLE_NAMES', 'Event', 'HELP_ICON_PATH', - 'HTTPError', 'IMAGE_EXTENSIONS', 'WIFE_LIST_IMAGE_THRESHOLD', 'LOG_PREFIX', + 'HTTPError', 'IMAGE_EXTENSIONS', 'LIST_FORWARD_THRESHOLD', 'LOG_PREFIX', 'LOLI_DOWNLOAD_LOG_PREFIX', 'LOLI_IMAGE_DIR_NAME', 'LOLI_MOBILE_UA', 'LOLICONAPP_API_URL', 'LOLICONAPP_TAGS', 'MemberCandidate', 'Message', 'MessageSegment', 'Path', diff --git a/tests/test_wife_list_image.py b/tests/test_wife_list_image.py deleted file mode 100644 index 60ae924..0000000 --- a/tests/test_wife_list_image.py +++ /dev/null @@ -1,194 +0,0 @@ -"""老婆列表:条目超过阈值时渲染成图片,不超过时保持纯文本。 - -测试不依赖框架环境:渲染模块直接用 PIL 跑真实渲染并校验 PNG 头;_send_wife_list 用 -AST 提取 + 注入 fake 依赖,验证「超过阈值发图片、未超过发文本」这一行为分支。 -""" - -import io -import ast -import asyncio -import tempfile -import unittest -from typing import Any -from pathlib import Path - -from PIL import Image, ImageFont - -ROOT = Path(__file__).resolve().parents[1] -PACKAGE = ROOT / 'TodayWaifu' -LIST_IMAGE_PATH = PACKAGE / 'list_image.py' -DAILY_PATH = PACKAGE / 'daily.py' -CONSTANTS_PATH = PACKAGE / 'constants.py' - -JPEG_MAGIC = b'\xff\xd8\xff' - - -def _fake_font(size: int = 24, weight: float = 630) -> ImageFont.ImageFont: - """替代框架的 core_font:测试只关心能否出图,不关心字体。""" - return ImageFont.load_default() - - -def _load_list_image_module() -> dict[str, Any]: - """按 AST 加载 list_image.py,剥掉框架导入并注入假依赖(CI 里没有 gsuid_core)。""" - tree = ast.parse(LIST_IMAGE_PATH.read_text(encoding='utf-8-sig')) - tree.body = [ - node - for node in tree.body - if not ( - isinstance(node, ast.ImportFrom) - and (node.level == 1 or (node.module or '').startswith('gsuid_core')) - ) - ] - globals_dict: dict[str, Any] = { - 'core_font': _fake_font, - # 指向不存在的目录,渲染于是走渐变兜底,测试不依赖本机角色图 - 'get_res_path': lambda name='': Path(tempfile.gettempdir()) / '__no_role_pile__', - } - exec(compile(tree, str(LIST_IMAGE_PATH), 'exec'), globals_dict) - return globals_dict - - -def _constant(name: str) -> Any: - tree = ast.parse(CONSTANTS_PATH.read_text(encoding='utf-8-sig')) - for node in tree.body: - if not isinstance(node, ast.Assign) or not isinstance(node.targets[0], ast.Name): - continue - if node.targets[0].id != name: - continue - return eval(compile(ast.Expression(node.value), '', 'eval'), {'__builtins__': {}}, {}) - raise AssertionError(f'常量 {name} 不存在') - - -def _extract_send_wife_list(items: list[tuple[int, str, str]], sent: list[Any]) -> Any: - """提取 _send_wife_list,注入 fake 依赖后返回协程函数。""" - tree = ast.parse(DAILY_PATH.read_text(encoding='utf-8-sig')) - function = next( - node - for node in tree.body - if isinstance(node, ast.AsyncFunctionDef) and node.name == '_send_wife_list' - ) - future = ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0) - module = ast.Module(body=[future, function], type_ignores=[]) - ast.fix_missing_locations(module) - - async def fake_items(ev: Any, mode: str) -> tuple[str, list[tuple[int, str, str]]]: - return '今日老婆列表:', items - - async def fake_safe_send(bot: Any, message: Any) -> None: - sent.append(message) - - async def fake_run_blocking(func: Any, *args: Any) -> Any: - return func(*args) - - async def fake_image_message(data: bytes) -> str: - return f'IMAGE:{len(data)}' - - globals_dict: dict[str, Any] = { - 'WIFE_LIST_IMAGE_THRESHOLD': _constant('WIFE_LIST_IMAGE_THRESHOLD'), - 'LOG_PREFIX': '[测试]', - 'logger': type('L', (), {'debug': staticmethod(lambda *a, **k: None)})(), - '_wife_list_items': fake_items, - '_wife_list_text_from_items': lambda title, rows: f'TEXT:{len(rows)}', - '_safe_send': fake_safe_send, - 'run_blocking': fake_run_blocking, - 'render_wife_list_image': lambda title, rows: JPEG_MAGIC + b'fake', - '_image_message': fake_image_message, - } - exec(compile(module, str(DAILY_PATH), 'exec'), globals_dict) - return globals_dict['_send_wife_list'] - - -class RenderWifeListImageTests(unittest.TestCase): - def setUp(self) -> None: - self.mod = _load_list_image_module() - self.render = self.mod['render_wife_list_image'] - - def test_renders_png_for_various_sizes(self) -> None: - for count in (0, 1, 5, 6, 31): - with self.subTest(count=count): - items = [(i, f'群友{i}', f'角色{i}') for i in range(1, count + 1)] - data = self.render('今日老婆列表:', items) - self.assertTrue(data.startswith(JPEG_MAGIC), '必须输出合法图片') - # 空列表只有标题,画布本就很小 - if count: - self.assertGreater(len(data), 1000) - - def test_long_names_do_not_break_rendering(self) -> None: - items = [(1, '很长的群昵称' * 6, '今汐'), (2, '乙', '名字很长的角色' * 4)] - self.assertTrue(self.render('今日老婆列表:', items).startswith(JPEG_MAGIC)) - - def test_rows_are_single_column(self) -> None: - """竖排单列:条目越多图越高、宽度基本不变。""" - small = self.render('今日老婆列表:', [(i, f'群友{i}', f'角色{i}') for i in range(1, 4)]) - large = self.render('今日老婆列表:', [(i, f'群友{i}', f'角色{i}') for i in range(1, 40)]) - small_box = Image.open(io.BytesIO(small)).size - large_box = Image.open(io.BytesIO(large)).size - self.assertEqual(small_box[0], large_box[0], '单列时宽度不应随条数变化') - self.assertGreater(large_box[1], small_box[1] * 5, '高度应随条数显著增长') - - def test_index_column_is_not_drawn_from_item_key(self) -> None: - """序号必须按显示顺序重新编号。 - - items 的首元素是排序用的时间戳,直接画出来会变成一串长数字。 - """ - src = LIST_IMAGE_PATH.read_text(encoding='utf-8') - self.assertIn('enumerate(items, 1)', src) - self.assertNotIn('str(index)', src) - - def test_long_item_key_does_not_widen_row(self) -> None: - """首元素(时间戳)不参与排版,故不影响列宽。""" - short_key = self.render('今日老婆列表:', [(1, '甲', '乙')]) - long_key = self.render('今日老婆列表:', [(1789001234, '甲', '乙')]) - self.assertEqual( - Image.open(io.BytesIO(short_key)).size, - Image.open(io.BytesIO(long_key)).size, - ) - - -class _FakeEvent: - user_id = '123456' - group_id = '654321' - - -class SendWifeListBranchTests(unittest.TestCase): - def test_above_threshold_sends_image(self) -> None: - sent: list[Any] = [] - items = [(i, f'群友{i}', f'角色{i}') for i in range(1, 7)] - send = _extract_send_wife_list(items, sent) - asyncio.run(send(object(), _FakeEvent())) - self.assertEqual(len(sent), 1) - self.assertTrue(str(sent[0]).startswith('IMAGE:'), f'应为图片,实际 {sent[0]}') - - def test_at_threshold_sends_text(self) -> None: - sent: list[Any] = [] - items = [(i, f'群友{i}', f'角色{i}') for i in range(1, 6)] - send = _extract_send_wife_list(items, sent) - asyncio.run(send(object(), _FakeEvent())) - self.assertEqual(len(sent), 1) - self.assertEqual(str(sent[0]), 'TEXT:5') - - def test_empty_list_sends_text(self) -> None: - sent: list[Any] = [] - send = _extract_send_wife_list([], sent) - asyncio.run(send(object(), _FakeEvent())) - self.assertEqual(str(sent[0]), 'TEXT:0') - - -class ThresholdConstantTests(unittest.TestCase): - def test_threshold_is_five(self) -> None: - self.assertEqual(_constant('WIFE_LIST_IMAGE_THRESHOLD'), 5) - - def test_old_forward_constant_is_gone(self) -> None: - """旧常量已由渲染阈值取代,避免两套阈值并存。""" - source = CONSTANTS_PATH.read_text(encoding='utf-8') - self.assertNotIn('LIST_FORWARD_THRESHOLD', source) - - def test_daily_uses_image_threshold(self) -> None: - source = DAILY_PATH.read_text(encoding='utf-8') - self.assertIn('WIFE_LIST_IMAGE_THRESHOLD', source) - self.assertIn('render_wife_list_image', source) - self.assertNotIn('MessageSegment.node', source) - - -if __name__ == '__main__': - unittest.main()