From 0be86ea8174de138d4c377e68fbacfcf16902f40 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 27 Jul 2026 00:34:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wave109):=20=E6=8A=BD=E7=A6=BBsticker?= =?UTF-8?q?=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2052=E5=8D=95?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从sticker_engine.py抽离数据类/常量/位置解析/贴纸列表解析 - sticker_engine保留全部导出,完全向后兼容 - 新增52个纯逻辑单测,覆盖配置解析、位置计算、边界校验 - sticker_engine.py: 612→489行 (-123行, -20%) --- .../worker/video_processing/sticker_engine.py | 179 +------ packages/domain/sticker_config.py | 314 ++++++++++++ tests/unit/test_sticker_config.py | 478 ++++++++++++++++++ 3 files changed, 820 insertions(+), 151 deletions(-) create mode 100755 packages/domain/sticker_config.py create mode 100755 tests/unit/test_sticker_config.py diff --git a/apps/worker/video_processing/sticker_engine.py b/apps/worker/video_processing/sticker_engine.py index 67ff13397..b5269e382 100755 --- a/apps/worker/video_processing/sticker_engine.py +++ b/apps/worker/video_processing/sticker_engine.py @@ -11,121 +11,23 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from pathlib import Path from typing import Any +from packages.domain.sticker_config import ( # noqa: F401 向后兼容导出 + ImageStickerConfig, + POSITION_PRESETS, + STICKER_CATEGORIES, + StickerOverlayResult, + TextStickerConfig, + get_sticker_categories as _get_sticker_categories_base, + parse_stickers_from_config as _parse_stickers_base, + resolve_sticker_position, +) + logger = logging.getLogger(__name__) -# ── 预设贴纸分类 ────────────────────────────────────────────────────────────── - -# 预设贴纸分类(仅用于前端展示,后端不依赖具体素材) -STICKER_CATEGORIES = [ - ("emoji", "表情包"), - ("text", "文字花字"), - ("decoration", "装饰"), - ("arrow", "箭头指示"), - ("frame", "边框"), -] - -# 9宫格位置映射 -POSITION_PRESETS = { - "top_left": (0.05, 0.05), - "top_center": (0.5, 0.05), - "top_right": (0.95, 0.05), - "center_left": (0.05, 0.5), - "center": (0.5, 0.5), - "center_right": (0.95, 0.5), - "bottom_left": (0.05, 0.95), - "bottom_center": (0.5, 0.95), - "bottom_right": (0.95, 0.95), -} - - -# ── 数据模型 ────────────────────────────────────────────────────────────────── - - -@dataclass -class ImageStickerConfig: - """图片贴纸配置.""" - - enabled: bool = False - type: str = "image" # image / text - # 位置 - position: str = "top_right" # 9宫格预设 - x: float | None = None # 自定义x(像素或百分比) - y: float | None = None # 自定义y - x_unit: str = "percent" # pixel / percent - y_unit: str = "percent" - # 大小 - scale: float = 1.0 # 缩放比例(相对于原始大小) - width: int | None = None # 指定宽度(像素) - height: int | None = None # 指定高度(像素) - # 透明度 - opacity: float = 1.0 # 0.0~1.0 - # 时间范围 - start_time: float = 0.0 - duration: float = 0.0 # 0 表示持续到结束 - # 动画 - fade_in: float = 0.0 # 淡入时长(秒) - fade_out: float = 0.0 # 淡出时长 - # 层级 - z_index: int = 10 - # 素材 - image_url: str = "" # 图片URL或本地路径 - preset_id: str = "" # 预设贴纸ID - - -@dataclass -class TextStickerConfig: - """文字贴纸配置.""" - - enabled: bool = False - type: str = "text" - text: str = "" - # 字体 - font_size: int = 36 - font_color: str = "#FFFFFF" - font_family: str = "sans" - # 描边 - stroke_color: str = "#000000" - stroke_width: int = 2 - # 阴影 - shadow_color: str = "#000000" - shadow_x: int = 2 - shadow_y: int = 2 - shadow_alpha: float = 0.5 - # 位置 - position: str = "center" - x: float | None = None - y: float | None = None - x_unit: str = "percent" - y_unit: str = "percent" - # 时间范围 - start_time: float = 0.0 - duration: float = 0.0 - # 动画 - fade_in: float = 0.0 - fade_out: float = 0.0 - # 层级 - z_index: int = 10 - # 背景框 - bg_color: str = "" # 空表示无背景 - bg_padding: int = 8 - bg_alpha: float = 0.8 - bg_corner_radius: int = 8 - - -@dataclass -class StickerOverlayResult: - """贴纸叠加结果.""" - - filter_str: str # 滤镜字符串 - output_label: str # 输出标签 - extra_inputs: list[str] = field(default_factory=list) # 额外的输入文件路径 - - # ── 贴纸引擎 ────────────────────────────────────────────────────────────────── @@ -144,38 +46,18 @@ class StickerEngine: sticker_w: int = 0, sticker_h: int = 0, ) -> tuple[float, float]: - """解析贴纸位置(像素坐标). - - 优先级:自定义坐标 > 9宫格预设 - """ - # 先取预设的基准位置 - if config.position in POSITION_PRESETS: - px, py = POSITION_PRESETS[config.position] - else: - px, py = 0.5, 0.5 # 默认居中 - - # 自定义坐标覆盖 - if config.x is not None: - if config.x_unit == "percent": - px = config.x / 100.0 - else: - px = config.x / canvas_w if canvas_w > 0 else 0.5 - - if config.y is not None: - if config.y_unit == "percent": - py = config.y / 100.0 - else: - py = config.y / canvas_h if canvas_h > 0 else 0.5 - - # 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点) - x = px * canvas_w - sticker_w / 2 - y = py * canvas_h - sticker_h / 2 - - # 钳制在画布内 - x = max(0, min(x, canvas_w - sticker_w)) - y = max(0, min(y, canvas_h - sticker_h)) - - return x, y + """解析贴纸位置(像素坐标)(转发到 sticker_config 模块).""" + return resolve_sticker_position( + config.position, + config.x, + config.y, + config.x_unit, + config.y_unit, + canvas_w, + canvas_h, + sticker_w, + sticker_h, + ) @staticmethod def _build_overlay_filter( @@ -594,19 +476,14 @@ class StickerEngine: return None -# ── 便捷函数 ────────────────────────────────────────────────────────────────── +# ── 便捷函数(薄包装,转发到 sticker_config 模块) ──────────────────────────── def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]: - """从 plan.config.stickers 解析贴纸列表.""" - if not config: - return [] - stickers = config.get("stickers", []) - if not isinstance(stickers, list): - return [] - return stickers + """从 plan.config.stickers 解析贴纸列表(薄包装).""" + return _parse_stickers_base(config) def get_sticker_categories() -> list[tuple[str, str]]: - """获取贴纸分类列表.""" - return list(STICKER_CATEGORIES) + """获取贴纸分类列表(薄包装).""" + return _get_sticker_categories_base() diff --git a/packages/domain/sticker_config.py b/packages/domain/sticker_config.py new file mode 100755 index 000000000..2c66b3f5a --- /dev/null +++ b/packages/domain/sticker_config.py @@ -0,0 +1,314 @@ +"""贴纸配置领域模型 — 纯逻辑,无外部依赖. + +抽离自 sticker_engine.py 的数据类、常量和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# ── 预设贴纸分类 ────────────────────────────────────────────────────────────── + +STICKER_CATEGORIES = [ + ("emoji", "表情包"), + ("text", "文字花字"), + ("decoration", "装饰"), + ("arrow", "箭头指示"), + ("frame", "边框"), +] + +# 9宫格位置映射(归一化坐标 0-1) +POSITION_PRESETS: dict[str, tuple[float, float]] = { + "top_left": (0.05, 0.05), + "top_center": (0.5, 0.05), + "top_right": (0.95, 0.05), + "center_left": (0.05, 0.5), + "center": (0.5, 0.5), + "center_right": (0.95, 0.5), + "bottom_left": (0.05, 0.95), + "bottom_center": (0.5, 0.95), + "bottom_right": (0.95, 0.95), +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class ImageStickerConfig: + """图片贴纸配置.""" + + enabled: bool = False + type: str = "image" + # 位置 + position: str = "top_right" + x: float | None = None + y: float | None = None + x_unit: str = "percent" # pixel / percent + y_unit: str = "percent" + # 大小 + scale: float = 1.0 + width: int | None = None + height: int | None = None + # 透明度 + opacity: float = 1.0 + # 时间范围 + start_time: float = 0.0 + duration: float = 0.0 # 0 表示持续到结束 + # 动画 + fade_in: float = 0.0 + fade_out: float = 0.0 + # 层级 + z_index: int = 10 + # 素材 + image_url: str = "" + preset_id: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "ImageStickerConfig": + """从字典创建配置,带安全类型转换.""" + if not data or not isinstance(data, dict): + return cls() + + def safe_float(key: str, default: float) -> float: + try: + val = data.get(key, default) + return float(val) if val is not None else default + except (TypeError, ValueError): + return default + + def safe_int(key: str, default: int | None) -> int | None: + val = data.get(key, default) + if val is None: + return None + try: + return int(val) + except (TypeError, ValueError): + return default + + x_val = data.get("x") + y_val = data.get("y") + try: + x_float = float(x_val) if x_val is not None else None + except (TypeError, ValueError): + x_float = None + try: + y_float = float(y_val) if y_val is not None else None + except (TypeError, ValueError): + y_float = None + + return cls( + enabled=bool(data.get("enabled", False)), + type=str(data.get("type", "image")), + position=str(data.get("position", "top_right")), + x=x_float, + y=y_float, + x_unit=str(data.get("x_unit", "percent")), + y_unit=str(data.get("y_unit", "percent")), + scale=max(0.01, safe_float("scale", 1.0)), + width=safe_int("width", None), + height=safe_int("height", None), + opacity=max(0.0, min(1.0, safe_float("opacity", 1.0))), + start_time=max(0.0, safe_float("start_time", 0.0)), + duration=max(0.0, safe_float("duration", 0.0)), + fade_in=max(0.0, safe_float("fade_in", 0.0)), + fade_out=max(0.0, safe_float("fade_out", 0.0)), + z_index=safe_int("z_index", 10) or 10, + image_url=str(data.get("image_url", "")), + preset_id=str(data.get("preset_id", "")), + ) + + @property + def has_time_range(self) -> bool: + """是否有明确的时间范围.""" + return self.duration > 0 + + @property + def end_time(self) -> float: + """结束时间(仅当 duration>0 时有意义).""" + return self.start_time + max(0.0, self.duration) + + +@dataclass +class TextStickerConfig: + """文字贴纸配置.""" + + enabled: bool = False + type: str = "text" + text: str = "" + # 字体 + font_size: int = 36 + font_color: str = "#FFFFFF" + font_family: str = "sans" + # 描边 + stroke_color: str = "#000000" + stroke_width: int = 2 + # 阴影 + shadow_color: str = "#000000" + shadow_x: int = 2 + shadow_y: int = 2 + shadow_alpha: float = 0.5 + # 位置 + position: str = "center" + x: float | None = None + y: float | None = None + x_unit: str = "percent" + y_unit: str = "percent" + # 时间范围 + start_time: float = 0.0 + duration: float = 0.0 + # 动画 + fade_in: float = 0.0 + fade_out: float = 0.0 + # 层级 + z_index: int = 10 + # 背景框 + bg_color: str = "" + bg_padding: int = 8 + bg_alpha: float = 0.8 + bg_corner_radius: int = 8 + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "TextStickerConfig": + """从字典创建配置,带安全类型转换.""" + if not data or not isinstance(data, dict): + return cls() + + def safe_float(key: str, default: float) -> float: + try: + val = data.get(key, default) + return float(val) if val is not None else default + except (TypeError, ValueError): + return default + + def safe_int(key: str, default: int) -> int: + try: + val = data.get(key, default) + return int(val) if val is not None else default + except (TypeError, ValueError): + return default + + x_val = data.get("x") + y_val = data.get("y") + try: + x_float = float(x_val) if x_val is not None else None + except (TypeError, ValueError): + x_float = None + try: + y_float = float(y_val) if y_val is not None else None + except (TypeError, ValueError): + y_float = None + + return cls( + enabled=bool(data.get("enabled", False)), + type=str(data.get("type", "text")), + text=str(data.get("text", "")), + font_size=max(1, safe_int("font_size", 36)), + font_color=str(data.get("font_color", "#FFFFFF")), + font_family=str(data.get("font_family", "sans")), + stroke_color=str(data.get("stroke_color", "#000000")), + stroke_width=max(0, safe_int("stroke_width", 2)), + shadow_color=str(data.get("shadow_color", "#000000")), + shadow_x=safe_int("shadow_x", 2), + shadow_y=safe_int("shadow_y", 2), + shadow_alpha=max(0.0, min(1.0, safe_float("shadow_alpha", 0.5))), + position=str(data.get("position", "center")), + x=x_float, + y=y_float, + x_unit=str(data.get("x_unit", "percent")), + y_unit=str(data.get("y_unit", "percent")), + start_time=max(0.0, safe_float("start_time", 0.0)), + duration=max(0.0, safe_float("duration", 0.0)), + fade_in=max(0.0, safe_float("fade_in", 0.0)), + fade_out=max(0.0, safe_float("fade_out", 0.0)), + z_index=safe_int("z_index", 10), + bg_color=str(data.get("bg_color", "")), + bg_padding=max(0, safe_int("bg_padding", 8)), + bg_alpha=max(0.0, min(1.0, safe_float("bg_alpha", 0.8))), + bg_corner_radius=max(0, safe_int("bg_corner_radius", 8)), + ) + + @property + def has_background(self) -> bool: + """是否有背景框.""" + return bool(self.bg_color) + + @property + def has_time_range(self) -> bool: + """是否有明确的时间范围.""" + return self.duration > 0 + + +@dataclass +class StickerOverlayResult: + """贴纸叠加结果.""" + + filter_str: str + output_label: str + extra_inputs: list[str] = field(default_factory=list) + + +# ── 工具函数 ────────────────────────────────────────────────────────────────── + + +def resolve_sticker_position( + position: str, + x: float | None, + y: float | None, + x_unit: str, + y_unit: str, + canvas_w: int, + canvas_h: int, + sticker_w: int = 0, + sticker_h: int = 0, +) -> tuple[float, float]: + """解析贴纸位置(像素坐标). + + 优先级:自定义坐标 > 9宫格预设 + 返回贴纸左上角的像素坐标,已钳制在画布内。 + """ + # 先取预设的基准位置 + if position in POSITION_PRESETS: + px, py = POSITION_PRESETS[position] + else: + px, py = 0.5, 0.5 # 默认居中 + + # 自定义坐标覆盖 + if x is not None: + if x_unit == "percent": + px = max(0.0, min(1.0, x / 100.0)) + else: + px = x / canvas_w if canvas_w > 0 else 0.5 + + if y is not None: + if y_unit == "percent": + py = max(0.0, min(1.0, y / 100.0)) + else: + py = y / canvas_h if canvas_h > 0 else 0.5 + + # 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点) + pos_x = px * canvas_w - sticker_w / 2 + pos_y = py * canvas_h - sticker_h / 2 + + # 钳制在画布内 + pos_x = max(0, min(pos_x, canvas_w - sticker_w)) + pos_y = max(0, min(pos_y, canvas_h - sticker_h)) + + return pos_x, pos_y + + +def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]: + """从 plan.config.stickers 解析贴纸列表.""" + if not config: + return [] + stickers = config.get("stickers", []) + if not isinstance(stickers, list): + return [] + return stickers + + +def get_sticker_categories() -> list[tuple[str, str]]: + """获取贴纸分类列表.""" + return list(STICKER_CATEGORIES) diff --git a/tests/unit/test_sticker_config.py b/tests/unit/test_sticker_config.py new file mode 100755 index 000000000..b42b2ea6b --- /dev/null +++ b/tests/unit/test_sticker_config.py @@ -0,0 +1,478 @@ +"""sticker_config 模块单测 — 纯逻辑.""" + +from __future__ import annotations + +import pytest + +from packages.domain.sticker_config import ( + POSITION_PRESETS, + STICKER_CATEGORIES, + ImageStickerConfig, + StickerOverlayResult, + TextStickerConfig, + get_sticker_categories, + parse_stickers_from_config, + resolve_sticker_position, +) + +# ── 常量测试 ────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_position_presets_has_9_positions(self): + assert len(POSITION_PRESETS) == 9 + + def test_position_presets_normalized(self): + for name, (x, y) in POSITION_PRESETS.items(): + assert 0.0 <= x <= 1.0 + assert 0.0 <= y <= 1.0 + + def test_sticker_categories(self): + assert len(STICKER_CATEGORIES) >= 3 + assert ("emoji", "表情包") in STICKER_CATEGORIES + assert ("text", "文字花字") in STICKER_CATEGORIES + + +# ── ImageStickerConfig 测试 ────────────────────────────────────────────────── + + +class TestImageStickerDefaults: + def test_default_values(self): + cfg = ImageStickerConfig() + assert cfg.enabled is False + assert cfg.type == "image" + assert cfg.position == "top_right" + assert cfg.x is None + assert cfg.y is None + assert cfg.scale == 1.0 + assert cfg.opacity == 1.0 + assert cfg.start_time == 0.0 + assert cfg.duration == 0.0 + assert cfg.fade_in == 0.0 + assert cfg.fade_out == 0.0 + assert cfg.z_index == 10 + + +class TestImageStickerFromDict: + def test_none_returns_default(self): + cfg = ImageStickerConfig.from_dict(None) + assert cfg.position == "top_right" + assert cfg.scale == 1.0 + + def test_empty_dict_returns_default(self): + cfg = ImageStickerConfig.from_dict({}) + assert cfg.enabled is False + + def test_custom_values(self): + cfg = ImageStickerConfig.from_dict( + { + "enabled": True, + "position": "center", + "scale": 1.5, + "opacity": 0.8, + "start_time": 2.0, + "duration": 5.0, + "z_index": 20, + "image_url": "https://example.com/img.png", + } + ) + assert cfg.enabled is True + assert cfg.position == "center" + assert cfg.scale == 1.5 + assert cfg.opacity == 0.8 + assert cfg.start_time == 2.0 + assert cfg.duration == 5.0 + assert cfg.z_index == 20 + assert cfg.image_url == "https://example.com/img.png" + + def test_custom_xy_pixel(self): + cfg = ImageStickerConfig.from_dict( + { + "x": 100, + "y": 200, + "x_unit": "pixel", + "y_unit": "pixel", + } + ) + assert cfg.x == 100.0 + assert cfg.y == 200.0 + assert cfg.x_unit == "pixel" + assert cfg.y_unit == "pixel" + + def test_opacity_clamped(self): + cfg = ImageStickerConfig.from_dict({"opacity": 1.5}) + assert cfg.opacity == 1.0 + cfg2 = ImageStickerConfig.from_dict({"opacity": -0.5}) + assert cfg2.opacity == 0.0 + + def test_scale_minimum(self): + cfg = ImageStickerConfig.from_dict({"scale": 0.001}) + assert cfg.scale == 0.01 + + def test_start_time_clamped(self): + cfg = ImageStickerConfig.from_dict({"start_time": -1}) + assert cfg.start_time == 0.0 + + def test_duration_clamped(self): + cfg = ImageStickerConfig.from_dict({"duration": -5}) + assert cfg.duration == 0.0 + + def test_invalid_x_returns_none(self): + cfg = ImageStickerConfig.from_dict({"x": "invalid"}) + assert cfg.x is None + + def test_width_height_int(self): + cfg = ImageStickerConfig.from_dict({"width": 200, "height": 100}) + assert cfg.width == 200 + assert cfg.height == 100 + + +class TestImageStickerProperties: + def test_has_time_range_true(self): + cfg = ImageStickerConfig(duration=5.0) + assert cfg.has_time_range is True + + def test_has_time_range_false(self): + cfg = ImageStickerConfig(duration=0.0) + assert cfg.has_time_range is False + + def test_end_time(self): + cfg = ImageStickerConfig(start_time=2.0, duration=3.0) + assert cfg.end_time == 5.0 + + def test_end_time_zero_duration(self): + cfg = ImageStickerConfig(start_time=2.0, duration=0.0) + assert cfg.end_time == 2.0 + + +# ── TextStickerConfig 测试 ─────────────────────────────────────────────────── + + +class TestTextStickerDefaults: + def test_default_values(self): + cfg = TextStickerConfig() + assert cfg.enabled is False + assert cfg.type == "text" + assert cfg.text == "" + assert cfg.font_size == 36 + assert cfg.font_color == "#FFFFFF" + assert cfg.stroke_width == 2 + assert cfg.position == "center" + assert cfg.bg_color == "" + assert cfg.bg_padding == 8 + assert cfg.bg_alpha == 0.8 + assert cfg.z_index == 10 + + +class TestTextStickerFromDict: + def test_none_returns_default(self): + cfg = TextStickerConfig.from_dict(None) + assert cfg.font_size == 36 + + def test_custom_text(self): + cfg = TextStickerConfig.from_dict({"text": "Hello World", "font_size": 48}) + assert cfg.text == "Hello World" + assert cfg.font_size == 48 + + def test_font_color(self): + cfg = TextStickerConfig.from_dict({"font_color": "#FF0000"}) + assert cfg.font_color == "#FF0000" + + def test_stroke_config(self): + cfg = TextStickerConfig.from_dict( + { + "stroke_color": "#00FF00", + "stroke_width": 4, + } + ) + assert cfg.stroke_color == "#00FF00" + assert cfg.stroke_width == 4 + + def test_shadow_config(self): + cfg = TextStickerConfig.from_dict( + { + "shadow_x": 4, + "shadow_y": 4, + "shadow_alpha": 0.7, + } + ) + assert cfg.shadow_x == 4 + assert cfg.shadow_y == 4 + assert cfg.shadow_alpha == 0.7 + + def test_background_config(self): + cfg = TextStickerConfig.from_dict( + { + "bg_color": "#000000", + "bg_padding": 12, + "bg_alpha": 0.9, + "bg_corner_radius": 10, + } + ) + assert cfg.bg_color == "#000000" + assert cfg.bg_padding == 12 + assert cfg.bg_alpha == 0.9 + assert cfg.bg_corner_radius == 10 + + def test_font_size_minimum(self): + cfg = TextStickerConfig.from_dict({"font_size": 0}) + assert cfg.font_size == 1 + + def test_stroke_width_negative_clamped(self): + cfg = TextStickerConfig.from_dict({"stroke_width": -2}) + assert cfg.stroke_width == 0 + + def test_shadow_alpha_clamped(self): + cfg = TextStickerConfig.from_dict({"shadow_alpha": 1.5}) + assert cfg.shadow_alpha == 1.0 + + def test_bg_alpha_clamped(self): + cfg = TextStickerConfig.from_dict({"bg_alpha": -0.5}) + assert cfg.bg_alpha == 0.0 + + def test_invalid_font_size_falls_back(self): + cfg = TextStickerConfig.from_dict({"font_size": "large"}) + assert cfg.font_size == 36 + + +class TestTextStickerProperties: + def test_has_background_true(self): + cfg = TextStickerConfig(bg_color="#000000") + assert cfg.has_background is True + + def test_has_background_false(self): + cfg = TextStickerConfig(bg_color="") + assert cfg.has_background is False + + def test_has_time_range_true(self): + cfg = TextStickerConfig(duration=3.0) + assert cfg.has_time_range is True + + +# ── StickerOverlayResult 测试 ──────────────────────────────────────────────── + + +class TestStickerOverlayResult: + def test_basic(self): + result = StickerOverlayResult(filter_str="overlay", output_label="[out]") + assert result.filter_str == "overlay" + assert result.output_label == "[out]" + assert result.extra_inputs == [] + + def test_with_extra_inputs(self): + result = StickerOverlayResult( + filter_str="overlay", + output_label="[out]", + extra_inputs=["sticker.png"], + ) + assert result.extra_inputs == ["sticker.png"] + + +# ── resolve_sticker_position 测试 ─────────────────────────────────────────── + + +class TestResolvePositionPresets: + def test_top_left(self): + x, y = resolve_sticker_position( + "top_left", + None, + None, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + # 0.05 * 1000 - 50 = 0, 0.05 * 500 - 25 = 0 + assert x == pytest.approx(0.0) + assert y == pytest.approx(0.0) + + def test_center(self): + x, y = resolve_sticker_position( + "center", + None, + None, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + # 0.5 * 1000 - 50 = 450, 0.5 * 500 - 25 = 225 + assert x == pytest.approx(450.0) + assert y == pytest.approx(225.0) + + def test_bottom_right(self): + x, y = resolve_sticker_position( + "bottom_right", + None, + None, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + # 0.95 * 1000 - 50 = 900, 0.95 * 500 - 25 = 450 + assert x == pytest.approx(900.0) + assert y == pytest.approx(450.0) + + def test_invalid_position_defaults_center(self): + x, y = resolve_sticker_position( + "invalid_pos", + None, + None, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + assert x == pytest.approx(450.0) + assert y == pytest.approx(225.0) + + +class TestResolvePositionCustomPercent: + def test_custom_percent(self): + x, y = resolve_sticker_position( + "center", + 25.0, + 75.0, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + # 0.25 * 1000 - 50 = 200, 0.75 * 500 - 25 = 350 + assert x == pytest.approx(200.0) + assert y == pytest.approx(350.0) + + def test_percent_clamped_0_100(self): + x, y = resolve_sticker_position( + "center", + 150.0, + -50.0, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + # x=100% → 1.0*1000-50=950, y=0% → 0*500-25=钳制到0 + assert x == pytest.approx(900.0) + assert y == pytest.approx(0.0) + + +class TestResolvePositionCustomPixel: + def test_custom_pixel(self): + x, y = resolve_sticker_position( + "center", + 200.0, + 300.0, + "pixel", + "pixel", + canvas_w=1000, + canvas_h=500, + sticker_w=100, + sticker_h=50, + ) + # 200/1000 = 0.2 → 0.2*1000-50=150, 300/500=0.6 → 0.6*500-25=275 + assert x == pytest.approx(150.0) + assert y == pytest.approx(275.0) + + +class TestResolvePositionEdgeCases: + def test_zero_canvas(self): + x, y = resolve_sticker_position( + "center", + 50.0, + 50.0, + "pixel", + "pixel", + canvas_w=0, + canvas_h=0, + sticker_w=10, + sticker_h=10, + ) + # canvas=0 时用默认 0.5, 0.5 + assert x == pytest.approx(0.0) + assert y == pytest.approx(0.0) + + def test_zero_sticker_size(self): + x, y = resolve_sticker_position( + "center", + None, + None, + "percent", + "percent", + canvas_w=1000, + canvas_h=500, + sticker_w=0, + sticker_h=0, + ) + assert x == pytest.approx(500.0) + assert y == pytest.approx(250.0) + + def test_clamped_when_sticker_larger_than_canvas(self): + # 贴纸比画布大时,钳制到0(x=0, y=0) + x, y = resolve_sticker_position( + "top_left", + None, + None, + "percent", + "percent", + canvas_w=100, + canvas_h=100, + sticker_w=200, + sticker_h=200, + ) + # 位置为左上角0.05 → 钳制到 0 + assert x == 0.0 + assert y == 0.0 + + +# ── parse_stickers_from_config 测试 ───────────────────────────────────────── + + +class TestParseStickersFromConfig: + def test_none_config(self): + assert parse_stickers_from_config(None) == [] + + def test_empty_dict(self): + assert parse_stickers_from_config({}) == [] + + def test_stickers_list(self): + cfg = {"stickers": [{"type": "image"}, {"type": "text"}]} + result = parse_stickers_from_config(cfg) + assert len(result) == 2 + + def test_stickers_not_list(self): + cfg = {"stickers": "not_a_list"} + assert parse_stickers_from_config(cfg) == [] + + def test_empty_stickers_list(self): + cfg = {"stickers": []} + assert parse_stickers_from_config(cfg) == [] + + +# ── get_sticker_categories 测试 ───────────────────────────────────────────── + + +class TestGetStickerCategories: + def test_returns_list(self): + result = get_sticker_categories() + assert isinstance(result, list) + assert len(result) > 0 + + def test_returns_copy(self): + a = get_sticker_categories() + b = get_sticker_categories() + assert a is not b + assert a == b -- 2.54.0