test(wave134): 贴纸引擎纯逻辑抽离 + 113单测 #1048

Merged
auto-approve-bot merged 1 commits from test/wave134-sticker-engine-pure into develop 2026-07-28 09:34:21 +08:00
2 changed files with 1314 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
"""贴纸引擎纯逻辑模块.
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
便于单元测试,也方便被其他模块复用。
"""
from __future__ import annotations
from typing import Any, Optional
# ── 安全类型转换 ──────────────────────────────────────────────────────────────
def safe_float(val: Any) -> Optional[float]:
"""安全转换为 float.
Args:
val: 任意值
Returns:
float 值,失败返回 None
"""
if val is None:
return None
try:
result = float(val)
if result != result: # NaN check
return None
return result
except (ValueError, TypeError):
return None
def safe_int(val: Any, default: int = 0) -> int:
"""安全转换为 int.
Args:
val: 任意值
default: 默认值
Returns:
int 值,失败返回默认值
"""
if val is None:
return default
try:
result = int(float(val))
return result
except (ValueError, TypeError):
return default
def safe_bool(val: Any) -> bool:
"""安全转换为 bool.
Args:
val: 任意值
Returns:
bool 值
"""
if isinstance(val, bool):
return val
if val is None:
return False
if isinstance(val, str):
return val.lower() in ("true", "1", "yes", "on")
return bool(val)
# ── 尺寸估算 ──────────────────────────────────────────────────────────────────
def estimate_sticker_size(
canvas_w: int,
canvas_h: int,
scale: float = 1.0,
fixed_width: Optional[int] = None,
fixed_height: Optional[int] = None,
) -> tuple[int, int]:
"""估算贴纸尺寸.
如果指定了固定宽高,直接使用;否则按画布的 30% * scale 估算。
Args:
canvas_w: 画布宽度
canvas_h: 画布高度
scale: 缩放比例
fixed_width: 固定宽度(可选)
fixed_height: 固定高度(可选)
Returns:
(估算宽度, 估算高度)
"""
if fixed_width and fixed_height:
return (fixed_width, fixed_height)
base_ratio = 0.3
est_w = int(canvas_w * base_ratio * scale) if not fixed_width else fixed_width
est_h = int(canvas_h * base_ratio * scale) if not fixed_height else fixed_height
return (max(1, est_w), max(1, est_h))
def estimate_text_size(
text: str,
font_size: int,
) -> tuple[int, int]:
"""估算文字贴纸尺寸.
粗略估算:宽度 = 字数 * 字号 * 0.6,高度 = 字号 * 1.4
Args:
text: 文字内容
font_size: 字号
Returns:
(估算宽度, 估算高度)
"""
if not text:
return (0, 0)
est_w = int(len(text) * font_size * 0.6)
est_h = int(font_size * 1.4)
return (max(1, est_w), max(1, est_h))
# ── 时间计算 ──────────────────────────────────────────────────────────────────
def calculate_fade_out_start(
start_time: float,
duration: float,
fade_out: float,
) -> float:
"""计算淡出开始时间.
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
fade_out: 淡出时长(秒)
Returns:
淡出开始时间(秒),最小为 0
"""
if fade_out <= 0 or duration <= 0:
return 0.0
fade_start = start_time + duration - fade_out
return max(0.0, fade_start)
def calculate_end_time(start_time: float, duration: float) -> float:
"""计算结束时间.
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
Returns:
结束时间(秒)
"""
if duration <= 0:
return start_time
return start_time + duration
def has_time_range(duration: float) -> bool:
"""是否有时间范围限制.
Args:
duration: 持续时长(秒)
Returns:
duration > 0 时返回 True
"""
return duration > 0
# ── 滤镜组件构建 ──────────────────────────────────────────────────────────────
def build_scale_filter(
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 1.0,
) -> Optional[str]:
"""构建缩放滤镜.
优先使用固定宽高,否则按比例缩放。
scale=1.0 且无固定尺寸时返回 None。
Args:
width: 固定宽度(可选)
height: 固定高度(可选)
scale: 缩放比例
Returns:
scale 滤镜字符串,不需要缩放时返回 None
"""
if width and height:
return f"scale={width}:{height}"
if scale != 1.0:
return f"scale=iw*{scale}:ih*{scale}"
return None
def build_opacity_filter(opacity: float) -> Optional[str]:
"""构建透明度滤镜.
Args:
opacity: 不透明度 0.0~1.0
Returns:
colorchannelmixer 滤镜字符串,完全不透明时返回 None
"""
if opacity >= 1.0:
return None
safe_opacity = max(0.0, min(1.0, opacity))
return f"colorchannelmixer=aa={safe_opacity}"
def build_image_fade_filters(
start_time: float,
duration: float,
fade_in: float = 0.0,
fade_out: float = 0.0,
) -> list[str]:
"""构建图片贴纸淡入淡出滤镜列表.
使用 FFmpeg fade 滤镜(alpha 通道)。
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
Returns:
fade 滤镜字符串列表
"""
filters: list[str] = []
if fade_in > 0:
filters.append(f"fade=in:st={start_time}:d={fade_in}:alpha=1")
if fade_out > 0 and duration > 0:
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
filters.append(f"fade=out:st={fade_out_start}:d={fade_out}:alpha=1")
return filters
def build_enable_expr(
start_time: float,
duration: float,
) -> str:
"""构建 enable 表达式(时间范围).
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
Returns:
enable 表达式字符串(包含开头的冒号),无时间限制时返回空字符串
"""
if duration <= 0:
return ""
end_time = calculate_end_time(start_time, duration)
return f":enable='between(t,{start_time},{end_time})'"
# ── drawtext 文字贴纸相关 ─────────────────────────────────────────────────────
def escape_drawtext_text(text: str) -> str:
"""转义 drawtext 中的特殊字符.
转义冒号和单引号。
Args:
text: 原始文字
Returns:
转义后的文字
"""
result = text.replace(":", "\\:")
result = result.replace("'", "\\'")
return result
def build_drawtext_alpha_expr(
start_time: float,
duration: float,
fade_in: float = 0.0,
fade_out: float = 0.0,
) -> str:
"""构建 drawtext 的 alpha 淡入淡出表达式.
drawtext 没有直接的 fade 滤镜,用 alpha 表达式模拟。
Args:
start_time: 开始时间(秒)
duration: 持续时长(秒)
fade_in: 淡入时长(秒)
fade_out: 淡出时长(秒)
Returns:
alpha 表达式字符串,无淡入淡出时返回 "1"
"""
parts: list[str] = []
if fade_in > 0:
fade_in_end = start_time + fade_in
parts.append(f"if(lt(t,{fade_in_end}),(t-{start_time})/{fade_in},1)")
if fade_out > 0 and duration > 0:
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
end_time = calculate_end_time(start_time, duration)
parts.append(f"if(gt(t,{fade_out_start}),({end_time}-t)/{fade_out},1)")
if not parts:
return "1"
return "*".join(parts)
def build_stroke_params(
stroke_width: int = 0,
stroke_color: str = "black",
) -> list[str]:
"""构建 drawtext 描边参数.
Args:
stroke_width: 描边宽度(0 表示无描边)
stroke_color: 描边颜色
Returns:
描边参数列表
"""
if stroke_width <= 0:
return []
return [
f"borderw={stroke_width}",
f"bordercolor={stroke_color}",
]
def build_shadow_params(
shadow_alpha: float = 0.0,
shadow_x: int = 2,
shadow_y: int = 2,
shadow_color: str = "black",
) -> list[str]:
"""构建 drawtext 阴影参数.
Args:
shadow_alpha: 阴影透明度(0 表示无阴影)
shadow_x: 阴影 X 偏移
shadow_y: 阴影 Y 偏移
shadow_color: 阴影颜色
Returns:
阴影参数列表
"""
if shadow_alpha <= 0:
return []
safe_alpha = max(0.0, min(1.0, shadow_alpha))
return [
f"shadowx={shadow_x}",
f"shadowy={shadow_y}",
f"shadowcolor={shadow_color}@{safe_alpha}",
]
# ── 贴纸排序与过滤 ────────────────────────────────────────────────────────────
def sort_stickers_by_z_index(
stickers: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""按 z_index 排序贴纸.
z_index 小的在底层,大的在上层。
相同 z_index 保持原顺序(稳定排序)。
Args:
stickers: 贴纸配置列表
Returns:
排序后的贴纸列表
"""
return sorted(stickers, key=lambda s: safe_int(s.get("z_index"), 10))
def filter_enabled_stickers(
stickers: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""过滤出启用的贴纸.
Args:
stickers: 贴纸配置列表
Returns:
启用的贴纸列表
"""
result = []
for s in stickers:
enabled = s.get("enabled", True)
if safe_bool(enabled):
result.append(s)
return result
def count_sticker_types(
stickers: list[dict[str, Any]],
) -> dict[str, int]:
"""统计各类型贴纸数量.
Args:
stickers: 贴纸配置列表
Returns:
类型计数字典
"""
counts: dict[str, int] = {}
for s in stickers:
stype = s.get("type", "image")
counts[stype] = counts.get(stype, 0) + 1
return counts
# ── overlay 滤镜构建 ──────────────────────────────────────────────────────────
def build_overlay_position(
pos_x: float,
pos_y: float,
) -> str:
"""构建 overlay 位置参数.
Args:
pos_x: X 坐标
pos_y: Y 坐标
Returns:
overlay 位置字符串 "x:y"
"""
return f"{pos_x:.0f}:{pos_y:.0f}"
def build_pre_filter_label(idx: int) -> str:
"""构建贴纸预处理后的标签名.
Args:
idx: 贴纸索引
Returns:
滤镜标签字符串
"""
return f"sticker_{idx}_scaled"
# ── 验证函数 ──────────────────────────────────────────────────────────────────
def validate_image_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
"""验证图片贴纸配置.
Args:
sticker: 贴纸配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 图片路径
image_path = sticker.get("image_path", "")
if not image_path and not sticker.get("asset_id"):
errors.append("图片贴纸需要 image_path 或 asset_id")
# 透明度范围
opacity = safe_float(sticker.get("opacity", 1.0))
if opacity is not None and (opacity < 0 or opacity > 1):
errors.append("opacity 必须在 0~1 之间")
# 缩放比例
scale = safe_float(sticker.get("scale", 1.0))
if scale is not None and scale <= 0:
errors.append("scale 必须大于 0")
# 时间参数
duration = safe_float(sticker.get("duration", 0))
if duration is not None and duration < 0:
errors.append("duration 不能为负数")
start_time = safe_float(sticker.get("start_time", 0))
if start_time is not None and start_time < 0:
errors.append("start_time 不能为负数")
return (len(errors) == 0, errors)
def validate_text_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
"""验证文字贴纸配置.
Args:
sticker: 贴纸配置字典
Returns:
(是否合法, 错误信息列表)
"""
errors: list[str] = []
# 文字内容
text = sticker.get("text", "")
if not text:
errors.append("文字贴纸需要 text 内容")
# 字号
font_size = safe_int(sticker.get("font_size", 36))
if font_size <= 0:
errors.append("font_size 必须大于 0")
# 颜色
font_color = sticker.get("font_color", "white")
if not font_color:
errors.append("font_color 不能为空")
# 时间参数
duration = safe_float(sticker.get("duration", 0))
if duration is not None and duration < 0:
errors.append("duration 不能为负数")
return (len(errors) == 0, errors)
+780
View File
@@ -0,0 +1,780 @@
"""贴纸引擎纯逻辑单元测试."""
from __future__ import annotations
import pytest
from video_processing.sticker_engine_pure import (
build_drawtext_alpha_expr,
build_enable_expr,
build_image_fade_filters,
build_opacity_filter,
build_overlay_position,
build_pre_filter_label,
build_scale_filter,
build_shadow_params,
build_stroke_params,
calculate_end_time,
calculate_fade_out_start,
count_sticker_types,
escape_drawtext_text,
estimate_sticker_size,
estimate_text_size,
filter_enabled_stickers,
has_time_range,
safe_bool,
safe_float,
safe_int,
sort_stickers_by_z_index,
validate_image_sticker,
validate_text_sticker,
)
# ─────────────────────────────────────────────────────────────────────────────
# 安全类型转换测试
# ─────────────────────────────────────────────────────────────────────────────
class TestSafeFloat:
"""safe_float 测试."""
def test_int_input(self):
"""整数输入."""
assert safe_float(42) == 42.0
def test_float_input(self):
"""浮点数输入."""
assert safe_float(3.14) == 3.14
def test_string_number(self):
"""字符串数字."""
assert safe_float("3.14") == 3.14
def test_string_int(self):
"""字符串整数."""
assert safe_float("100") == 100.0
def test_none_input(self):
"""None 输入."""
assert safe_float(None) is None
def test_invalid_string(self):
"""无效字符串."""
assert safe_float("abc") is None
def test_empty_string(self):
"""空字符串."""
assert safe_float("") is None
def test_zero(self):
"""零值."""
assert safe_float(0) == 0.0
def test_negative(self):
"""负值."""
assert safe_float(-5.5) == -5.5
class TestSafeInt:
"""safe_int 测试."""
def test_int_input(self):
"""整数输入."""
assert safe_int(42) == 42
def test_float_input(self):
"""浮点数输入(截断)."""
assert safe_int(3.7) == 3
def test_string_number(self):
"""字符串数字."""
assert safe_int("42") == 42
def test_none_input(self):
"""None 输入用默认值."""
assert safe_int(None) == 0
def test_none_custom_default(self):
"""None 输入自定义默认值."""
assert safe_int(None, default=10) == 10
def test_invalid_string(self):
"""无效字符串."""
assert safe_int("abc") == 0
def test_negative(self):
"""负值."""
assert safe_int(-5) == -5
def test_zero(self):
"""零值."""
assert safe_int(0) == 0
class TestSafeBool:
"""safe_bool 测试."""
def test_true_bool(self):
"""True."""
assert safe_bool(True) is True
def test_false_bool(self):
"""False."""
assert safe_bool(False) is False
def test_none(self):
"""None -> False."""
assert safe_bool(None) is False
def test_string_true(self):
"""字符串 true."""
assert safe_bool("true") is True
def test_string_yes(self):
"""字符串 yes."""
assert safe_bool("yes") is True
def test_string_one(self):
"""字符串 1."""
assert safe_bool("1") is True
def test_string_false(self):
"""字符串 false."""
assert safe_bool("false") is False
def test_int_one(self):
"""整数 1 -> True."""
assert safe_bool(1) is True
def test_int_zero(self):
"""整数 0 -> False."""
assert safe_bool(0) is False
def test_empty_list(self):
"""空列表 -> False."""
assert safe_bool([]) is False
# ─────────────────────────────────────────────────────────────────────────────
# 尺寸估算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEstimateStickerSize:
"""贴纸尺寸估算测试."""
def test_default_scale(self):
"""默认 scale=1.0."""
w, h = estimate_sticker_size(1000, 1000)
assert w == 300 # 1000 * 0.3 * 1.0
assert h == 300
def test_custom_scale(self):
"""自定义缩放."""
w, h = estimate_sticker_size(1000, 1000, scale=0.5)
assert w == 150
assert h == 150
def test_fixed_width_height(self):
"""固定宽高."""
w, h = estimate_sticker_size(1000, 1000, fixed_width=200, fixed_height=100)
assert w == 200
assert h == 100
def test_scale_2x(self):
"""2倍缩放."""
w, h = estimate_sticker_size(800, 600, scale=2.0)
assert w == 480 # 800 * 0.3 * 2
assert h == 360 # 600 * 0.3 * 2
def test_zero_canvas(self):
"""零画布尺寸,返回最小 1."""
w, h = estimate_sticker_size(0, 0)
assert w >= 1
assert h >= 1
class TestEstimateTextSize:
"""文字尺寸估算测试."""
def test_normal_text(self):
"""普通文字."""
w, h = estimate_text_size("Hello", 36)
assert w == int(5 * 36 * 0.6)
assert h == int(36 * 1.4)
def test_empty_text(self):
"""空文字."""
w, h = estimate_text_size("", 36)
assert w == 0
assert h == 0
def test_large_font(self):
"""大字号."""
w, h = estimate_text_size("A", 72)
assert w == int(1 * 72 * 0.6)
assert h == int(72 * 1.4)
def test_chinese_chars(self):
"""中文字符."""
w, h = estimate_text_size("你好世界", 48)
assert w == int(4 * 48 * 0.6)
assert h == int(48 * 1.4)
# ─────────────────────────────────────────────────────────────────────────────
# 时间计算测试
# ─────────────────────────────────────────────────────────────────────────────
class TestCalculateFadeOutStart:
"""淡出开始时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_fade_out_start(10, 30, 2) == pytest.approx(38.0)
def test_no_fade_out(self):
"""无淡出."""
assert calculate_fade_out_start(10, 30, 0) == 0.0
def test_negative_fade_out(self):
"""负淡出."""
assert calculate_fade_out_start(10, 30, -1) == 0.0
def test_zero_duration(self):
"""零时长."""
assert calculate_fade_out_start(10, 0, 2) == 0.0
def test_fade_out_longer_than_duration(self):
"""淡出超过时长,返回 0."""
# start=10, dur=5, fade=10 -> 10+5-10 = 5 > 0
assert calculate_fade_out_start(10, 5, 10) == pytest.approx(5.0)
def test_fade_out_starts_before_zero(self):
"""淡出开始时间在 0 之前,钳制到 0."""
# start=0, dur=3, fade=5 -> 0+3-5 = -2 -> 0
assert calculate_fade_out_start(0, 3, 5) == 0.0
class TestCalculateEndTime:
"""结束时间计算测试."""
def test_normal_case(self):
"""正常情况."""
assert calculate_end_time(10, 30) == 40.0
def test_zero_duration(self):
"""零时长."""
assert calculate_end_time(10, 0) == 10.0
def test_negative_duration(self):
"""负时长."""
assert calculate_end_time(10, -5) == 10.0
def test_zero_start(self):
"""零开始."""
assert calculate_end_time(0, 100) == 100.0
class TestHasTimeRange:
"""时间范围判断测试."""
def test_positive_duration(self):
"""正时长."""
assert has_time_range(30) is True
def test_zero_duration(self):
"""零时长."""
assert has_time_range(0) is False
def test_negative_duration(self):
"""负时长."""
assert has_time_range(-5) is False
# ─────────────────────────────────────────────────────────────────────────────
# 滤镜构建测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildScaleFilter:
"""缩放滤镜构建测试."""
def test_fixed_width_height(self):
"""固定宽高."""
result = build_scale_filter(width=200, height=100)
assert result == "scale=200:100"
def test_scale_only(self):
"""仅缩放."""
result = build_scale_filter(scale=0.5)
assert result == "scale=iw*0.5:ih*0.5"
def test_no_scaling_needed(self):
"""无需缩放."""
result = build_scale_filter(scale=1.0)
assert result is None
def test_scale_2x(self):
"""2倍缩放."""
result = build_scale_filter(scale=2.0)
assert result == "scale=iw*2.0:ih*2.0"
def test_fixed_overrides_scale(self):
"""固定宽高优先于 scale."""
result = build_scale_filter(width=100, height=50, scale=0.5)
assert result == "scale=100:50"
class TestBuildOpacityFilter:
"""透明度滤镜构建测试."""
def test_partial_opacity(self):
"""部分透明."""
result = build_opacity_filter(0.5)
assert result == "colorchannelmixer=aa=0.5"
def test_fully_opaque(self):
"""完全不透明."""
result = build_opacity_filter(1.0)
assert result is None
def test_fully_transparent(self):
"""完全透明."""
result = build_opacity_filter(0.0)
assert result == "colorchannelmixer=aa=0.0"
def test_opacity_above_1_clamped(self):
"""超过 1 被钳制."""
result = build_opacity_filter(1.5)
assert result is None
def test_opacity_below_0_clamped(self):
"""低于 0 被钳制."""
result = build_opacity_filter(-0.5)
assert result == "colorchannelmixer=aa=0.0"
class TestBuildImageFadeFilters:
"""图片淡入淡出滤镜测试."""
def test_fade_in_only(self):
"""仅淡入."""
result = build_image_fade_filters(10, 30, fade_in=1.0)
assert len(result) == 1
assert "fade=in:st=10:d=1.0:alpha=1" in result[0]
def test_fade_out_only(self):
"""仅淡出."""
result = build_image_fade_filters(10, 30, fade_out=2.0)
assert len(result) == 1
assert "fade=out" in result[0]
assert "st=38.0" in result[0] # 10 + 30 - 2 = 38
def test_fade_in_and_out(self):
"""淡入+淡出."""
result = build_image_fade_filters(0, 10, fade_in=1.0, fade_out=1.0)
assert len(result) == 2
assert "fade=in" in result[0]
assert "fade=out" in result[1]
def test_no_fade(self):
"""无淡入淡出."""
result = build_image_fade_filters(10, 30)
assert len(result) == 0
def test_zero_duration_no_fade_out(self):
"""零时长不生成淡出."""
result = build_image_fade_filters(10, 0, fade_out=1.0)
assert len(result) == 0
class TestBuildEnableExpr:
"""enable 表达式构建测试."""
def test_normal_duration(self):
"""正常时长."""
result = build_enable_expr(10, 30)
assert "between(t,10,40" in result
assert "enable" in result
def test_zero_duration(self):
"""零时长返回空."""
result = build_enable_expr(10, 0)
assert result == ""
def test_negative_duration(self):
"""负时长返回空."""
result = build_enable_expr(10, -5)
assert result == ""
def test_zero_start(self):
"""从零开始."""
result = build_enable_expr(0, 100)
assert "t,0,100" in result
# ─────────────────────────────────────────────────────────────────────────────
# drawtext 相关测试
# ─────────────────────────────────────────────────────────────────────────────
class TestEscapeDrawtextText:
"""文字转义测试."""
def test_no_special_chars(self):
"""无特殊字符."""
assert escape_drawtext_text("Hello") == "Hello"
def test_colon_escaped(self):
"""冒号转义."""
assert escape_drawtext_text("a:b") == "a\\:b"
def test_quote_escaped(self):
"""单引号转义."""
assert escape_drawtext_text("it's") == "it\\'s"
def test_multiple_special_chars(self):
"""多个特殊字符."""
assert escape_drawtext_text("a:b:c'd") == "a\\:b\\:c\\'d"
def test_empty_string(self):
"""空字符串."""
assert escape_drawtext_text("") == ""
class TestBuildDrawtextAlphaExpr:
"""drawtext alpha 表达式测试."""
def test_no_fade(self):
"""无淡入淡出."""
assert build_drawtext_alpha_expr(10, 30) == "1"
def test_fade_in_only(self):
"""仅淡入."""
result = build_drawtext_alpha_expr(10, 30, fade_in=2.0)
assert "if(lt(t,12.0)" in result
assert "(t-10)/2.0" in result
def test_fade_out_only(self):
"""仅淡出."""
result = build_drawtext_alpha_expr(10, 30, fade_out=3.0)
assert "if(gt(t,37" in result
assert "-t)/3.0" in result
def test_fade_in_and_out(self):
"""淡入+淡出(相乘)."""
result = build_drawtext_alpha_expr(0, 10, fade_in=1.0, fade_out=1.0)
assert "*" in result
assert result.count("if(") == 2
def test_zero_duration_no_fade_out(self):
"""零时长不生成淡出."""
result = build_drawtext_alpha_expr(10, 0, fade_out=1.0)
assert result == "1"
class TestBuildStrokeParams:
"""描边参数测试."""
def test_no_stroke(self):
"""无描边."""
result = build_stroke_params(0)
assert len(result) == 0
def test_with_stroke(self):
"""有描边."""
result = build_stroke_params(2, "red")
assert len(result) == 2
assert "borderw=2" in result
assert "bordercolor=red" in result
def test_negative_width(self):
"""负宽度."""
result = build_stroke_params(-1)
assert len(result) == 0
class TestBuildShadowParams:
"""阴影参数测试."""
def test_no_shadow(self):
"""无阴影."""
result = build_shadow_params(0)
assert len(result) == 0
def test_with_shadow(self):
"""有阴影."""
result = build_shadow_params(0.5, 3, 4, "black")
assert len(result) == 3
assert "shadowx=3" in result
assert "shadowy=4" in result
assert "shadowcolor=black@0.5" in result
def test_shadow_alpha_clamped(self):
"""透明度钳制."""
result = build_shadow_params(1.5)
assert "shadowcolor=black@1.0" in result[2]
# ─────────────────────────────────────────────────────────────────────────────
# 贴纸排序与过滤测试
# ─────────────────────────────────────────────────────────────────────────────
class TestSortStickersByZIndex:
"""贴纸排序测试."""
def test_sorted_by_z_index(self):
"""按 z_index 排序."""
stickers = [
{"z_index": 20, "name": "top"},
{"z_index": 5, "name": "bottom"},
{"z_index": 10, "name": "middle"},
]
result = sort_stickers_by_z_index(stickers)
assert result[0]["name"] == "bottom"
assert result[1]["name"] == "middle"
assert result[2]["name"] == "top"
def test_same_z_index_preserves_order(self):
"""相同 z_index 保持原顺序."""
stickers = [
{"z_index": 10, "name": "first"},
{"z_index": 10, "name": "second"},
]
result = sort_stickers_by_z_index(stickers)
assert result[0]["name"] == "first"
assert result[1]["name"] == "second"
def test_empty_list(self):
"""空列表."""
assert sort_stickers_by_z_index([]) == []
def test_default_z_index_10(self):
"""无 z_index 默认 10."""
stickers = [
{"z_index": 5, "name": "low"},
{"name": "default"},
]
result = sort_stickers_by_z_index(stickers)
assert result[0]["name"] == "low"
assert result[1]["name"] == "default"
class TestFilterEnabledStickers:
"""启用贴纸过滤测试."""
def test_all_enabled(self):
"""全部启用."""
stickers = [{"enabled": True}, {"enabled": True}]
assert len(filter_enabled_stickers(stickers)) == 2
def test_mixed(self):
"""混合."""
stickers = [
{"enabled": True, "name": "a"},
{"enabled": False, "name": "b"},
{"enabled": True, "name": "c"},
]
result = filter_enabled_stickers(stickers)
assert len(result) == 2
assert result[0]["name"] == "a"
def test_default_enabled(self):
"""默认启用."""
stickers = [{"name": "a"}]
result = filter_enabled_stickers(stickers)
assert len(result) == 1
def test_empty_list(self):
"""空列表."""
assert filter_enabled_stickers([]) == []
class TestCountStickerTypes:
"""贴纸类型统计测试."""
def test_mixed_types(self):
"""混合类型."""
stickers = [
{"type": "image"},
{"type": "text"},
{"type": "image"},
]
counts = count_sticker_types(stickers)
assert counts["image"] == 2
assert counts["text"] == 1
def test_default_type(self):
"""默认 image."""
stickers = [{}]
counts = count_sticker_types(stickers)
assert counts["image"] == 1
def test_empty_list(self):
"""空列表."""
assert count_sticker_types([]) == {}
# ─────────────────────────────────────────────────────────────────────────────
# overlay 相关测试
# ─────────────────────────────────────────────────────────────────────────────
class TestBuildOverlayPosition:
"""overlay 位置构建测试."""
def test_integer_position(self):
"""整数位置."""
assert build_overlay_position(100, 200) == "100:200"
def test_float_position_rounded(self):
"""浮点取整."""
assert build_overlay_position(100.6, 200.4) == "101:200"
def test_zero_position(self):
"""零位置."""
assert build_overlay_position(0, 0) == "0:0"
def test_negative_position(self):
"""负位置."""
assert build_overlay_position(-10, -20) == "-10:-20"
class TestBuildPreFilterLabel:
"""预处理标签构建测试."""
def test_normal_idx(self):
"""正常索引."""
assert build_pre_filter_label(3) == "sticker_3_scaled"
def test_zero_idx(self):
"""零索引."""
assert build_pre_filter_label(0) == "sticker_0_scaled"
# ─────────────────────────────────────────────────────────────────────────────
# 验证函数测试
# ─────────────────────────────────────────────────────────────────────────────
class TestValidateImageSticker:
"""图片贴纸验证测试."""
def test_valid_with_image_path(self):
"""有 image_path,合法."""
ok, errors = validate_image_sticker({"image_path": "/a.png"})
assert ok is True
assert len(errors) == 0
def test_valid_with_asset_id(self):
"""有 asset_id,合法."""
ok, errors = validate_image_sticker({"asset_id": "123"})
assert ok is True
def test_missing_image_source(self):
"""缺图片来源."""
ok, errors = validate_image_sticker({})
assert ok is False
assert any("image_path" in e or "asset_id" in e for e in errors)
def test_opacity_out_of_range(self):
"""透明度超范围."""
ok, errors = validate_image_sticker(
{
"image_path": "/a.png",
"opacity": 1.5,
}
)
assert ok is False
assert any("opacity" in e for e in errors)
def test_negative_scale(self):
"""负缩放."""
ok, errors = validate_image_sticker(
{
"image_path": "/a.png",
"scale": -0.5,
}
)
assert ok is False
assert any("scale" in e for e in errors)
def test_negative_duration(self):
"""负时长."""
ok, errors = validate_image_sticker(
{
"image_path": "/a.png",
"duration": -10,
}
)
assert ok is False
assert any("duration" in e for e in errors)
def test_multiple_errors(self):
"""多个错误."""
ok, errors = validate_image_sticker(
{
"opacity": 1.5,
"duration": -1,
"start_time": -5,
}
)
assert ok is False
assert len(errors) >= 3
class TestValidateTextSticker:
"""文字贴纸验证测试."""
def test_valid(self):
"""合法配置."""
ok, errors = validate_text_sticker(
{
"text": "Hello",
"font_size": 36,
"font_color": "white",
}
)
assert ok is True
assert len(errors) == 0
def test_empty_text(self):
"""空文字."""
ok, errors = validate_text_sticker({"text": ""})
assert ok is False
assert any("text" in e for e in errors)
def test_zero_font_size(self):
"""零字号."""
ok, errors = validate_text_sticker(
{
"text": "Hi",
"font_size": 0,
}
)
assert ok is False
assert any("font_size" in e for e in errors)
def test_empty_font_color(self):
"""空颜色."""
ok, errors = validate_text_sticker(
{
"text": "Hi",
"font_color": "",
}
)
assert ok is False
assert any("font_color" in e for e in errors)
def test_negative_duration(self):
"""负时长."""
ok, errors = validate_text_sticker(
{
"text": "Hi",
"duration": -5,
}
)
assert ok is False
assert any("duration" in e for e in errors)