Files
xiaoxia-saas/tests/unit/test_sticker_engine_pure.py
xiaoxia 456718ad84
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Failing after 13m7s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 13m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 12m57s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Failing after 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m33s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 24s
CI/CD Pipeline / CI Gate (push) Has been skipped
test(wave134): 贴纸引擎纯逻辑抽离 + 113单测 (#1048)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-28 09:34:21 +08:00

781 lines
24 KiB
Python
Executable File

"""贴纸引擎纯逻辑单元测试."""
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)