"""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