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