Files
xiaoxia-saas/tests/unit/test_sticker_engine_pure.py
xiaoxia d7e362a637
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
test(wave199): sticker_engine_pure 单测补全 +141测 (#1165)
2026-07-30 00:25:30 +08:00

832 lines
26 KiB
Python
Executable File

"""sticker_engine_pure 单元测试."""
from apps.worker.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,
)
# ── safe_float ──────────────────────────────────────────────────────────────────
class TestSafeFloat:
def test_none_returns_none(self):
assert safe_float(None) is None
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_int_string(self):
assert safe_float("3.14") == 3.14
def test_string_integer_string(self):
assert safe_float("100") == 100.0
def test_invalid_string(self):
assert safe_float("abc") is None
def test_empty_string(self):
assert safe_float("") is None
def test_nan_returns_none(self):
import math
assert safe_float(float("nan")) is None
assert math.isnan(float("nan")) # 确认NaN判断生效
def test_negative_number(self):
assert safe_float(-5.5) == -5.5
def test_zero(self):
assert safe_float(0) == 0.0
def test_boolean(self):
assert safe_float(True) == 1.0
assert safe_float(False) == 0.0
# ── safe_int ──────────────────────────────────────────────────────────────────
class TestSafeInt:
def test_none_returns_default(self):
assert safe_int(None) == 0
assert safe_int(None, default=5) == 5
def test_int_input(self):
assert safe_int(42) == 42
def test_float_input_truncates(self):
assert safe_int(3.7) == 3
assert safe_int(3.2) == 3
def test_string_integer(self):
assert safe_int("100") == 100
def test_string_float(self):
assert safe_int("3.9") == 3
def test_invalid_string_returns_default(self):
assert safe_int("abc") == 0
assert safe_int("abc", default=-1) == -1
def test_empty_string(self):
assert safe_int("") == 0
def test_negative_number(self):
assert safe_int(-10) == -10
def test_zero(self):
assert safe_int(0) == 0
def test_boolean(self):
assert safe_int(True) == 1
assert safe_int(False) == 0
# ── safe_bool ────────────────────────────────────────────────────────────────
class TestSafeBool:
def test_boolean_passthrough(self):
assert safe_bool(True) is True
assert safe_bool(False) is False
def test_none_returns_false(self):
assert safe_bool(None) is False
def test_string_true_variants(self):
assert safe_bool("true") is True
assert safe_bool("True") is True
assert safe_bool("TRUE") is True
assert safe_bool("1") is True
assert safe_bool("yes") is True
assert safe_bool("YES") is True
assert safe_bool("on") is True
assert safe_bool("On") is True
def test_string_false_variants(self):
assert safe_bool("false") is False
assert safe_bool("0") is False
assert safe_bool("no") is False
assert safe_bool("off") is False
def test_numeric_values(self):
assert safe_bool(1) is True
assert safe_bool(0) is False
assert safe_bool(-1) is True
def test_empty_string(self):
assert safe_bool("") is False
def test_list_truthy_falsy(self):
assert safe_bool([1]) is True
assert safe_bool([]) is False
# ── estimate_sticker_size ────────────────────────────────────────────────
class TestEstimateStickerSize:
def test_default_scale(self):
w, h = estimate_sticker_size(1000, 800)
assert w == 300 # 1000 * 0.3
assert h == 240 # 800 * 0.3
def test_custom_scale(self):
w, h = estimate_sticker_size(1000, 800, scale=2.0)
assert w == 600
assert h == 480
def test_fixed_width_and_height(self):
w, h = estimate_sticker_size(1000, 800, fixed_width=200, fixed_height=150)
assert w == 200
assert h == 150
def test_fixed_width_only(self):
w, h = estimate_sticker_size(1000, 800, fixed_width=500)
assert w == 500
assert h == 240 # 仍然按比例算高
def test_fixed_height_only(self):
w, h = estimate_sticker_size(1000, 800, fixed_height=400)
assert w == 300
assert h == 400
def test_minimum_size(self):
w, h = estimate_sticker_size(1, 1, scale=0.01)
assert w >= 1
assert h >= 1
def test_zero_canvas(self):
w, h = estimate_sticker_size(0, 0)
assert w >= 1
assert h >= 1
def test_scale_zero(self):
w, h = estimate_sticker_size(1000, 800, scale=0)
assert w >= 1
assert h >= 1
# ── estimate_text_size ──────────────────────────────────────────────
class TestEstimateTextSize:
def test_normal_text(self):
w, h = estimate_text_size("hello", 20)
assert w == int(5 * 20 * 0.6)
assert h == int(20 * 1.4)
def test_empty_text(self):
w, h = estimate_text_size("", 20)
assert w == 0
assert h == 0
def test_chinese_text(self):
w, h = estimate_text_size("你好世界", 30)
assert w == int(4 * 30 * 0.6)
assert h == int(30 * 1.4)
def test_minimum_size(self):
w, h = estimate_text_size("a", 1)
assert w >= 1
assert h >= 1
def test_single_char(self):
w, h = estimate_text_size("x", 100)
assert w == int(1 * 100 * 0.6)
assert h == int(100 * 1.4)
# ── calculate_fade_out_start ────────────────────────────────────────
class TestCalculateFadeOutStart:
def test_normal_case(self):
assert calculate_fade_out_start(10, 20, 3) == 27.0 # 10 + 20 - 3
def test_zero_fade_out(self):
assert calculate_fade_out_start(10, 20, 0) == 0.0
def test_zero_duration(self):
assert calculate_fade_out_start(10, 0, 3) == 0.0
def test_negative_fade_out(self):
assert calculate_fade_out_start(10, 20, -1) == 0.0
def test_fade_longer_than_duration(self):
result = calculate_fade_out_start(5, 3, 10)
assert result == 0.0 # max(0, 5+3-10) = max(0, -2) = 0
def test_start_at_zero(self):
assert calculate_fade_out_start(0, 10, 2) == 8.0
def test_float_values(self):
assert calculate_fade_out_start(1.5, 5.5, 2.0) == 5.0
# ── calculate_end_time ────────────────────────────────────────────
class TestCalculateEndTime:
def test_normal_case(self):
assert calculate_end_time(10, 5) == 15.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_start_at_zero(self):
assert calculate_end_time(0, 10) == 10.0
def test_float_values(self):
assert calculate_end_time(1.5, 2.5) == 4.0
# ── has_time_range ──────────────────────────────────────────────
class TestHasTimeRange:
def test_positive_duration(self):
assert has_time_range(10) is True
assert has_time_range(0.1) 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
# ── build_scale_filter ────────────────────────────────────────
class TestBuildScaleFilter:
def test_fixed_width_height(self):
assert build_scale_filter(width=100, height=200) == "scale=100:200"
def test_scale_only(self):
assert build_scale_filter(scale=0.5) == "scale=iw*0.5:ih*0.5"
def test_default_no_scale(self):
assert build_scale_filter() is None
assert build_scale_filter(scale=1.0) is None
def test_width_only_returns_none(self):
# 只有width没有height,且scale=1.0,返回None
assert build_scale_filter(width=100) is None
def test_height_only_returns_none(self):
assert build_scale_filter(height=200) is None
def test_scale_with_width_height_overrides_scale(self):
# width和height都有时优先
assert build_scale_filter(width=100, height=200, scale=0.5) == "scale=100:200"
# ── build_opacity_filter ──────────────────────────────────────
class TestBuildOpacityFilter:
def test_full_opacity(self):
assert build_opacity_filter(1.0) is None
assert build_opacity_filter(1.5) is None # 大于1也返回None
def test_partial_opacity(self):
assert build_opacity_filter(0.5) == "colorchannelmixer=aa=0.5"
def test_zero_opacity(self):
assert build_opacity_filter(0.0) == "colorchannelmixer=aa=0.0"
def test_negative_clamped(self):
assert build_opacity_filter(-0.5) == "colorchannelmixer=aa=0.0"
def test_above_one_clamped(self):
# 大于1的情况:>=1.0返回None
assert build_opacity_filter(2.0) is None
# ── build_image_fade_filters ────────────────────────────────────
class TestBuildImageFadeFilters:
def test_no_fade(self):
assert build_image_fade_filters(10, 20) == []
def test_fade_in_only(self):
result = build_image_fade_filters(10, 20, fade_in=2)
assert len(result) == 1
assert "fade=in:st=10:d=2:alpha=1" in result[0]
def test_fade_out_only(self):
result = build_image_fade_filters(10, 20, fade_out=3)
assert len(result) == 1
assert "fade=out:st=27:d=3:alpha=1" in result[0]
def test_both_fades(self):
result = build_image_fade_filters(10, 20, fade_in=2, fade_out=3)
assert len(result) == 2
assert "fade=in" in result[0]
assert "fade=out" in result[1]
def test_fade_out_zero_duration_skipped(self):
result = build_image_fade_filters(10, 0, fade_out=3)
assert result == []
def test_fade_out_negative_duration(self):
result = build_image_fade_filters(10, -5, fade_out=3)
assert result == []
# ── build_enable_expr ────────────────────────────────────────
class TestBuildEnableExpr:
def test_positive_duration(self):
result = build_enable_expr(10, 5)
assert result == ":enable='between(t,10,15)'"
def test_zero_duration(self):
assert build_enable_expr(10, 0) == ""
def test_negative_duration(self):
assert build_enable_expr(10, -1) == ""
def test_start_at_zero(self):
result = build_enable_expr(0, 10)
assert result == ":enable='between(t,0,10)'"
def test_float_values(self):
result = build_enable_expr(1.5, 2.5)
assert "between(t,1.5,4.0)" in result
# ── escape_drawtext_text ──────────────────────────────────────
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:c") == "a\\:b\\:c"
def test_single_quote_escaped(self):
assert escape_drawtext_text("a'b'c") == "a\\'b\\'c"
def test_both_special_chars(self):
result = escape_drawtext_text("it's: test")
assert result == "it\\'s\\: test"
def test_empty_string(self):
assert escape_drawtext_text("") == ""
def test_backslash_not_escaped(self):
# 只转义冒号和单引号
assert escape_drawtext_text("a\\b") == "a\\b"
# ── build_drawtext_alpha_expr ──────────────────────────────────
class TestBuildDrawtextAlphaExpr:
def test_no_fade(self):
assert build_drawtext_alpha_expr(10, 20) == "1"
def test_fade_in_only(self):
result = build_drawtext_alpha_expr(10, 20, fade_in=2)
assert result == "if(lt(t,12),(t-10)/2,1)"
def test_fade_out_only(self):
result = build_drawtext_alpha_expr(10, 20, fade_out=3)
assert result == "if(gt(t,27),(30-t)/3,1)"
def test_both_fades(self):
result = build_drawtext_alpha_expr(10, 20, fade_in=2, fade_out=3)
assert "if(lt(t," in result
assert "if(gt(t," in result
assert result.count("*") == 1 # 两部分相乘
def test_fade_out_zero_duration(self):
result = build_drawtext_alpha_expr(10, 0, fade_out=3)
assert result == "1"
def test_zero_fade_in(self):
result = build_drawtext_alpha_expr(10, 20, fade_in=0)
assert result == "1"
# ── build_stroke_params ──────────────────────────────────────
class TestBuildStrokeParams:
def test_no_stroke(self):
assert build_stroke_params() == []
assert build_stroke_params(stroke_width=0) == []
assert build_stroke_params(stroke_width=-1) == []
def test_default_color(self):
result = build_stroke_params(stroke_width=2)
assert len(result) == 2
assert "borderw=2" in result
assert "bordercolor=black" in result
def test_custom_color(self):
result = build_stroke_params(stroke_width=3, stroke_color="red")
assert "borderw=3" in result
assert "bordercolor=red" in result
# ── build_shadow_params ──────────────────────────────────────
class TestBuildShadowParams:
def test_no_shadow(self):
assert build_shadow_params() == []
assert build_shadow_params(shadow_alpha=0) == []
assert build_shadow_params(shadow_alpha=-1) == []
def test_default_values(self):
result = build_shadow_params(shadow_alpha=0.5)
assert len(result) == 3
assert "shadowx=2" in result
assert "shadowy=2" in result
assert "shadowcolor=black@0.5" in result
def test_custom_offset(self):
result = build_shadow_params(shadow_alpha=0.3, shadow_x=5, shadow_y=7, shadow_color="red")
assert "shadowx=5" in result
assert "shadowy=7" in result
assert "shadowcolor=red@0.3" in result
def test_alpha_clamped(self):
result = build_shadow_params(shadow_alpha=1.5)
assert "shadowcolor=black@1.0" in result
def test_alpha_negative_clamped_to_zero(self):
# 负数会触发<=0分支,返回空列表
assert build_shadow_params(shadow_alpha=-0.5) == []
# ── sort_stickers_by_z_index ────────────────────────────────────
class TestSortStickersByZIndex:
def test_sorted_by_z_index(self):
stickers = [
{"name": "c", "z_index": 3},
{"name": "a", "z_index": 1},
{"name": "b", "z_index": 2},
]
result = sort_stickers_by_z_index(stickers)
assert [s["name"] for s in result] == ["a", "b", "c"]
def test_missing_z_index_defaults_to_10(self):
stickers = [
{"name": "low", "z_index": 5},
{"name": "no_z"}, # 默认10
{"name": "high", "z_index": 15},
]
result = sort_stickers_by_z_index(stickers)
assert [s["name"] for s in result] == ["low", "no_z", "high"]
def test_same_z_index_stable(self):
stickers = [
{"name": "first", "z_index": 5},
{"name": "second", "z_index": 5},
{"name": "third", "z_index": 5},
]
result = sort_stickers_by_z_index(stickers)
assert [s["name"] for s in result] == ["first", "second", "third"]
def test_empty_list(self):
assert sort_stickers_by_z_index([]) == []
def test_negative_z_index(self):
stickers = [
{"name": "neg", "z_index": -5},
{"name": "zero", "z_index": 0},
{"name": "pos", "z_index": 5},
]
result = sort_stickers_by_z_index(stickers)
assert [s["name"] for s in result] == ["neg", "zero", "pos"]
def test_original_not_modified(self):
stickers = [{"z_index": 3}, {"z_index": 1}]
original = list(stickers)
sort_stickers_by_z_index(stickers)
assert stickers == original
# ── filter_enabled_stickers ────────────────────────────────────
class TestFilterEnabledStickers:
def test_all_enabled(self):
stickers = [
{"name": "a", "enabled": True},
{"name": "b"}, # 默认True
]
result = filter_enabled_stickers(stickers)
assert len(result) == 2
def test_mixed_enabled(self):
stickers = [
{"name": "a", "enabled": True},
{"name": "b", "enabled": False},
{"name": "c"},
]
result = filter_enabled_stickers(stickers)
assert len(result) == 2
assert [s["name"] for s in result] == ["a", "c"]
def test_all_disabled(self):
stickers = [
{"name": "a", "enabled": False},
{"name": "b", "enabled": "false"},
]
result = filter_enabled_stickers(stickers)
assert len(result) == 0
def test_empty_list(self):
assert filter_enabled_stickers([]) == []
def test_string_enabled_values(self):
stickers = [
{"name": "a", "enabled": "true"},
{"name": "b", "enabled": "yes"},
{"name": "c", "enabled": "0"},
]
result = filter_enabled_stickers(stickers)
assert [s["name"] for s in result] == ["a", "b"]
# ── count_sticker_types ────────────────────────────────────────
class TestCountStickerTypes:
def test_multiple_types(self):
stickers = [
{"type": "image"},
{"type": "text"},
{"type": "image"},
{"type": "image"},
{"type": "emoji"},
]
result = count_sticker_types(stickers)
assert result == {"image": 3, "text": 1, "emoji": 1}
def test_default_type_image(self):
stickers = [
{"name": "a"}, # 无type字段
{"type": "text"},
]
result = count_sticker_types(stickers)
assert result == {"image": 1, "text": 1}
def test_empty_list(self):
assert count_sticker_types([]) == {}
def test_single_type(self):
stickers = [{"type": "text"} for _ in range(5)]
result = count_sticker_types(stickers)
assert result == {"text": 5}
# ── build_overlay_position ──────────────────────────────────────
class TestBuildOverlayPosition:
def test_integer_values(self):
assert build_overlay_position(100, 200) == "100:200"
def test_float_values_rounded(self):
assert build_overlay_position(100.6, 200.3) == "101:200"
def test_negative_values(self):
assert build_overlay_position(-10, -20) == "-10:-20"
def test_zero_values(self):
assert build_overlay_position(0, 0) == "0:0"
# ── build_pre_filter_label ─────────────────────────────────────
class TestBuildPreFilterLabel:
def test_index_zero(self):
assert build_pre_filter_label(0) == "sticker_0_scaled"
def test_positive_index(self):
assert build_pre_filter_label(5) == "sticker_5_scaled"
def test_large_index(self):
assert build_pre_filter_label(999) == "sticker_999_scaled"
# ── validate_image_sticker ──────────────────────────────────
class TestValidateImageSticker:
def test_valid_with_image_path(self):
valid, errors = validate_image_sticker({"image_path": "/path/to/img.png"})
assert valid is True
assert errors == []
def test_valid_with_asset_id(self):
valid, errors = validate_image_sticker({"asset_id": "asset_123"})
assert valid is True
assert errors == []
def test_missing_image_and_asset(self):
valid, errors = validate_image_sticker({})
assert valid is False
assert "image_path 或 asset_id" in errors[0]
def test_invalid_opacity_high(self):
valid, errors = validate_image_sticker(
{
"image_path": "a.png",
"opacity": 1.5,
}
)
assert valid is False
assert any("opacity" in e for e in errors)
def test_invalid_opacity_low(self):
valid, errors = validate_image_sticker(
{
"image_path": "a.png",
"opacity": -0.5,
}
)
assert valid is False
assert any("opacity" in e for e in errors)
def test_valid_opacity_boundary(self):
valid, _ = validate_image_sticker({"image_path": "a.png", "opacity": 0})
assert valid is True
valid, _ = validate_image_sticker({"image_path": "a.png", "opacity": 1})
assert valid is True
def test_invalid_scale_zero(self):
valid, errors = validate_image_sticker(
{
"image_path": "a.png",
"scale": 0,
}
)
assert valid is False
assert any("scale" in e for e in errors)
def test_invalid_scale_negative(self):
valid, errors = validate_image_sticker(
{
"image_path": "a.png",
"scale": -1,
}
)
assert valid is False
def test_invalid_duration_negative(self):
valid, errors = validate_image_sticker(
{
"image_path": "a.png",
"duration": -5,
}
)
assert valid is False
assert any("duration" in e for e in errors)
def test_invalid_start_time_negative(self):
valid, errors = validate_image_sticker(
{
"image_path": "a.png",
"start_time": -1,
}
)
assert valid is False
assert any("start_time" in e for e in errors)
def test_multiple_errors(self):
valid, errors = validate_image_sticker(
{
"opacity": 2.0,
"scale": -1,
"duration": -5,
}
)
assert valid is False
assert len(errors) >= 3
def test_valid_with_extra_fields(self):
valid, _ = validate_image_sticker(
{
"image_path": "a.png",
"extra_field": "ignored",
"z_index": 5,
}
)
assert valid is True
# ── validate_text_sticker ────────────────────────────────────
class TestValidateTextSticker:
def test_valid_text(self):
valid, errors = validate_text_sticker({"text": "hello"})
assert valid is True
assert errors == []
def test_missing_text(self):
valid, errors = validate_text_sticker({})
assert valid is False
assert any("text" in e for e in errors)
def test_empty_text(self):
valid, errors = validate_text_sticker({"text": ""})
assert valid is False
assert any("text" in e for e in errors)
def test_invalid_font_size_zero(self):
valid, errors = validate_text_sticker(
{
"text": "hello",
"font_size": 0,
}
)
assert valid is False
assert any("font_size" in e for e in errors)
def test_invalid_font_size_negative(self):
valid, errors = validate_text_sticker(
{
"text": "hello",
"font_size": -5,
}
)
assert valid is False
def test_missing_font_color(self):
valid, errors = validate_text_sticker(
{
"text": "hello",
"font_color": "",
}
)
assert valid is False
assert any("font_color" in e for e in errors)
def test_invalid_duration_negative(self):
valid, errors = validate_text_sticker(
{
"text": "hello",
"duration": -3,
}
)
assert valid is False
assert any("duration" in e for e in errors)
def test_default_font_size_valid(self):
# 默认36,有效
valid, _ = validate_text_sticker({"text": "hi"})
assert valid is True
def test_multiple_errors(self):
valid, errors = validate_text_sticker(
{
"text": "",
"font_size": -1,
"font_color": "",
}
)
assert valid is False
assert len(errors) >= 3