"""sticker_config 单元测试 - wave167 覆盖: - ImageStickerConfig 数据类 / from_dict / has_time_range / end_time - TextStickerConfig 数据类 / from_dict / has_background / has_time_range - StickerOverlayResult 数据类 - resolve_sticker_position 位置解析(9宫格/自定义坐标/单位转换/钳制) - parse_stickers_from_config 贴纸列表解析 - get_sticker_categories 分类列表 - 常量验证(POSITION_PRESETS / STICKER_CATEGORIES) """ import pytest from packages.domain.sticker_config import ( POSITION_PRESETS, STICKER_CATEGORIES, ImageStickerConfig, StickerOverlayResult, TextStickerConfig, get_sticker_categories, parse_stickers_from_config, resolve_sticker_position, ) # ============================================================ # 常量验证 # ============================================================ class TestConstants: def test_nine_position_presets(self): assert len(POSITION_PRESETS) == 9 def test_positions_in_normalized_range(self): for name, (x, y) in POSITION_PRESETS.items(): assert 0.0 <= x <= 1.0, f"{name} x out of range: {x}" assert 0.0 <= y <= 1.0, f"{name} y out of range: {y}" def test_top_left_is_near_zero(self): x, y = POSITION_PRESETS["top_left"] assert x < 0.1 assert y < 0.1 def test_bottom_right_is_near_one(self): x, y = POSITION_PRESETS["bottom_right"] assert x > 0.9 assert y > 0.9 def test_center_is_middle(self): x, y = POSITION_PRESETS["center"] assert x == 0.5 assert y == 0.5 def test_sticker_categories_not_empty(self): assert len(STICKER_CATEGORIES) >= 3 def test_sticker_categories_format(self): for cat in STICKER_CATEGORIES: assert len(cat) == 2 assert isinstance(cat[0], str) assert isinstance(cat[1], str) # ============================================================ # ImageStickerConfig # ============================================================ class TestImageStickerConfigDefaults: def test_default_values(self): cfg = ImageStickerConfig() assert cfg.enabled is False assert cfg.type == "image" assert cfg.position == "top_right" assert cfg.x is None assert cfg.y is None assert cfg.x_unit == "percent" assert cfg.y_unit == "percent" assert cfg.scale == 1.0 assert cfg.width is None assert cfg.height is None assert cfg.opacity == 1.0 assert cfg.start_time == 0.0 assert cfg.duration == 0.0 assert cfg.fade_in == 0.0 assert cfg.fade_out == 0.0 assert cfg.z_index == 10 assert cfg.image_url == "" assert cfg.preset_id == "" def test_has_time_range_false_when_zero(self): cfg = ImageStickerConfig() assert cfg.has_time_range is False def test_has_time_range_true_when_set(self): cfg = ImageStickerConfig(duration=5.0) assert cfg.has_time_range is True def test_end_time_calculation(self): cfg = ImageStickerConfig(start_time=2.0, duration=3.0) assert cfg.end_time == pytest.approx(5.0) def test_end_time_zero_duration(self): cfg = ImageStickerConfig(start_time=5.0, duration=0.0) assert cfg.end_time == pytest.approx(5.0) class TestImageStickerFromDict: def test_none_returns_default(self): cfg = ImageStickerConfig.from_dict(None) assert cfg.enabled is False assert cfg.position == "top_right" def test_empty_dict_returns_default(self): cfg = ImageStickerConfig.from_dict({}) assert cfg.enabled is False def test_not_dict_returns_default(self): cfg = ImageStickerConfig.from_dict("not_a_dict") assert cfg.enabled is False def test_enabled_true(self): cfg = ImageStickerConfig.from_dict({"enabled": True}) assert cfg.enabled is True def test_position_custom(self): cfg = ImageStickerConfig.from_dict({"position": "bottom_left"}) assert cfg.position == "bottom_left" def test_custom_coordinates(self): cfg = ImageStickerConfig.from_dict({"x": 30.0, "y": 50.0}) assert cfg.x == 30.0 assert cfg.y == 50.0 def test_string_coordinates_converted(self): cfg = ImageStickerConfig.from_dict({"x": "25.5", "y": "75.0"}) assert cfg.x == 25.5 assert cfg.y == 75.0 def test_invalid_coordinates_become_none(self): cfg = ImageStickerConfig.from_dict({"x": "invalid", "y": "abc"}) assert cfg.x is None assert cfg.y is None def test_scale_clamped_min(self): cfg = ImageStickerConfig.from_dict({"scale": 0.001}) assert cfg.scale == 0.01 def test_scale_normal(self): cfg = ImageStickerConfig.from_dict({"scale": 1.5}) assert cfg.scale == 1.5 def test_opacity_clamped_high(self): cfg = ImageStickerConfig.from_dict({"opacity": 1.5}) assert cfg.opacity == 1.0 def test_opacity_clamped_low(self): cfg = ImageStickerConfig.from_dict({"opacity": -0.5}) assert cfg.opacity == 0.0 def test_opacity_normal(self): cfg = ImageStickerConfig.from_dict({"opacity": 0.5}) assert cfg.opacity == 0.5 def test_start_time_clamped_non_negative(self): cfg = ImageStickerConfig.from_dict({"start_time": -5.0}) assert cfg.start_time == 0.0 def test_duration_clamped_non_negative(self): cfg = ImageStickerConfig.from_dict({"duration": -3.0}) assert cfg.duration == 0.0 def test_fade_in_non_negative(self): cfg = ImageStickerConfig.from_dict({"fade_in": -1.0}) assert cfg.fade_in == 0.0 def test_fade_out_non_negative(self): cfg = ImageStickerConfig.from_dict({"fade_out": -1.0}) assert cfg.fade_out == 0.0 def test_z_index_default_when_zero(self): cfg = ImageStickerConfig.from_dict({"z_index": 0}) # 0 or None → 10 assert cfg.z_index == 10 def test_z_index_custom(self): cfg = ImageStickerConfig.from_dict({"z_index": 5}) assert cfg.z_index == 5 def test_width_height(self): cfg = ImageStickerConfig.from_dict({"width": 200, "height": 100}) assert cfg.width == 200 assert cfg.height == 100 def test_width_height_none(self): cfg = ImageStickerConfig.from_dict({}) assert cfg.width is None assert cfg.height is None def test_image_url_and_preset_id(self): cfg = ImageStickerConfig.from_dict({"image_url": "http://img.png", "preset_id": "sticker_001"}) assert cfg.image_url == "http://img.png" assert cfg.preset_id == "sticker_001" def test_units_custom(self): cfg = ImageStickerConfig.from_dict({"x_unit": "pixel", "y_unit": "pixel"}) assert cfg.x_unit == "pixel" assert cfg.y_unit == "pixel" def test_invalid_numeric_values_use_default(self): cfg = ImageStickerConfig.from_dict({"scale": "not_a_number"}) assert cfg.scale == 1.0 # 回退到默认值 def test_invalid_int_values_use_default(self): cfg = ImageStickerConfig.from_dict({"z_index": "invalid"}) assert cfg.z_index == 10 # ============================================================ # TextStickerConfig # ============================================================ class TestTextStickerConfigDefaults: def test_default_values(self): cfg = TextStickerConfig() assert cfg.enabled is False assert cfg.type == "text" assert cfg.text == "" assert cfg.font_size == 36 assert cfg.font_color == "#FFFFFF" assert cfg.font_family == "sans" assert cfg.stroke_color == "#000000" assert cfg.stroke_width == 2 assert cfg.shadow_color == "#000000" assert cfg.shadow_x == 2 assert cfg.shadow_y == 2 assert cfg.shadow_alpha == 0.5 assert cfg.position == "center" assert cfg.start_time == 0.0 assert cfg.duration == 0.0 assert cfg.z_index == 10 assert cfg.bg_color == "" assert cfg.bg_padding == 8 assert cfg.bg_alpha == 0.8 assert cfg.bg_corner_radius == 8 def test_has_background_false_when_empty(self): cfg = TextStickerConfig() assert cfg.has_background is False def test_has_background_true_when_set(self): cfg = TextStickerConfig(bg_color="#FF0000") assert cfg.has_background is True def test_has_time_range_false(self): cfg = TextStickerConfig() assert cfg.has_time_range is False def test_has_time_range_true(self): cfg = TextStickerConfig(duration=10.0) assert cfg.has_time_range is True class TestTextStickerFromDict: def test_none_returns_default(self): cfg = TextStickerConfig.from_dict(None) assert cfg.enabled is False assert cfg.text == "" def test_empty_dict_returns_default(self): cfg = TextStickerConfig.from_dict({}) assert cfg.text == "" def test_not_dict_returns_default(self): cfg = TextStickerConfig.from_dict(123) assert cfg.text == "" def test_text_content(self): cfg = TextStickerConfig.from_dict({"text": "Hello World"}) assert cfg.text == "Hello World" def test_font_size_minimum_1(self): cfg = TextStickerConfig.from_dict({"font_size": 0}) assert cfg.font_size == 1 def test_font_size_normal(self): cfg = TextStickerConfig.from_dict({"font_size": 48}) assert cfg.font_size == 48 def test_font_color(self): cfg = TextStickerConfig.from_dict({"font_color": "#00FF00"}) assert cfg.font_color == "#00FF00" def test_stroke_width_non_negative(self): cfg = TextStickerConfig.from_dict({"stroke_width": -2}) assert cfg.stroke_width == 0 def test_stroke_width_normal(self): cfg = TextStickerConfig.from_dict({"stroke_width": 4}) assert cfg.stroke_width == 4 def test_shadow_alpha_clamped(self): cfg = TextStickerConfig.from_dict({"shadow_alpha": 1.5}) assert cfg.shadow_alpha == 1.0 def test_shadow_alpha_negative(self): cfg = TextStickerConfig.from_dict({"shadow_alpha": -0.5}) assert cfg.shadow_alpha == 0.0 def test_shadow_offset(self): cfg = TextStickerConfig.from_dict({"shadow_x": 5, "shadow_y": 3}) assert cfg.shadow_x == 5 assert cfg.shadow_y == 3 def test_position_custom(self): cfg = TextStickerConfig.from_dict({"position": "top_right"}) assert cfg.position == "top_right" def test_custom_coordinates(self): cfg = TextStickerConfig.from_dict({"x": 10, "y": 20}) assert cfg.x == 10.0 assert cfg.y == 20.0 def test_start_time_non_negative(self): cfg = TextStickerConfig.from_dict({"start_time": -1.0}) assert cfg.start_time == 0.0 def test_duration_non_negative(self): cfg = TextStickerConfig.from_dict({"duration": -2.0}) assert cfg.duration == 0.0 def test_fade_in_out_non_negative(self): cfg = TextStickerConfig.from_dict({"fade_in": -1, "fade_out": -1}) assert cfg.fade_in == 0.0 assert cfg.fade_out == 0.0 def test_z_index_default(self): cfg = TextStickerConfig.from_dict({}) assert cfg.z_index == 10 def test_bg_color(self): cfg = TextStickerConfig.from_dict({"bg_color": "#0000FF"}) assert cfg.bg_color == "#0000FF" def test_bg_padding_non_negative(self): cfg = TextStickerConfig.from_dict({"bg_padding": -5}) assert cfg.bg_padding == 0 def test_bg_alpha_clamped(self): cfg = TextStickerConfig.from_dict({"bg_alpha": 2.0}) assert cfg.bg_alpha == 1.0 def test_bg_corner_radius_non_negative(self): cfg = TextStickerConfig.from_dict({"bg_corner_radius": -3}) assert cfg.bg_corner_radius == 0 def test_invalid_numeric_values_use_default(self): cfg = TextStickerConfig.from_dict({"font_size": "big"}) assert cfg.font_size == 36 # 默认值 # ============================================================ # StickerOverlayResult # ============================================================ class TestStickerOverlayResult: def test_minimal_creation(self): r = StickerOverlayResult(filter_str="overlay=10:20", output_label="[out]") assert r.filter_str == "overlay=10:20" assert r.output_label == "[out]" assert r.extra_inputs == [] def test_with_extra_inputs(self): r = StickerOverlayResult( filter_str="overlay", output_label="[out]", extra_inputs=["img1.png", "img2.png"], ) assert len(r.extra_inputs) == 2 assert r.extra_inputs == ["img1.png", "img2.png"] def test_extra_inputs_independent_lists(self): r1 = StickerOverlayResult(filter_str="a", output_label="[o1]") r2 = StickerOverlayResult(filter_str="b", output_label="[o2]") r1.extra_inputs.append("test.png") assert r2.extra_inputs == [] # 独立 # ============================================================ # resolve_sticker_position # ============================================================ class TestResolveStickerPosition: CANVAS_W = 1920 CANVAS_H = 1080 def test_preset_top_left(self): x, y = resolve_sticker_position( "top_left", None, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 100, 50, ) # preset x=0.05, y=0.05 → 0.05*1920 - 50 = 96-50=46, 0.05*1080-25=54-25=29 assert x == pytest.approx(0.05 * 1920 - 50) assert y == pytest.approx(0.05 * 1080 - 25) def test_preset_center(self): x, y = resolve_sticker_position( "center", None, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 200, 100, ) # center 0.5,0.5 → 贴纸左上角居中 assert x == pytest.approx(1920 / 2 - 100) assert y == pytest.approx(1080 / 2 - 50) def test_preset_bottom_right(self): x, y = resolve_sticker_position( "bottom_right", None, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 100, 50, ) assert x == pytest.approx(0.95 * 1920 - 50) assert y == pytest.approx(0.95 * 1080 - 25) def test_invalid_preset_defaults_center(self): x, y = resolve_sticker_position( "invalid", None, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 100, 50, ) # 默认居中 assert x == pytest.approx(1920 / 2 - 50) assert y == pytest.approx(1080 / 2 - 25) def test_custom_percent_coordinates(self): x, y = resolve_sticker_position( "top_left", 30.0, 70.0, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 0, 0, ) # x=30% of 1920 = 576, y=70% of 1080 = 756 assert x == pytest.approx(576.0) assert y == pytest.approx(756.0) def test_custom_percent_clamped_0(self): x, y = resolve_sticker_position( "center", -10.0, -5.0, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 0, 0, ) assert x == 0.0 assert y == 0.0 def test_custom_percent_clamped_100(self): x, y = resolve_sticker_position( "center", 150.0, 120.0, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 0, 0, ) assert x == pytest.approx(1920.0) assert y == pytest.approx(1080.0) def test_custom_pixel_coordinates(self): x, y = resolve_sticker_position( "center", 500, 300, "pixel", "pixel", self.CANVAS_W, self.CANVAS_H, 0, 0, ) assert x == pytest.approx(500.0) assert y == pytest.approx(300.0) def test_x_only_override(self): # 只有x覆盖,y用预设 x, y = resolve_sticker_position( "top_left", 50.0, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 0, 0, ) assert x == pytest.approx(0.5 * 1920) # 50% assert y == pytest.approx(0.05 * 1080) # 预设 top_left def test_y_only_override(self): x, y = resolve_sticker_position( "top_left", None, 50.0, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 0, 0, ) assert x == pytest.approx(0.05 * 1920) assert y == pytest.approx(0.5 * 1080) def test_clamped_to_canvas_with_sticker_size(self): # 贴纸100x50,放在最右下角,不能超出画布 x, y = resolve_sticker_position( "bottom_right", None, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 100, 50, ) assert x <= self.CANVAS_W - 100 assert y <= self.CANVAS_H - 50 def test_clamped_left_top(self): # 很大的负偏移,被钳制在0 x, y = resolve_sticker_position( "center", 0.0, 0.0, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 200, 100, ) # 0%位置 + 贴纸尺寸的一半偏移 = -100, -50 → 钳制到 0, 0 assert x == 0 assert y == 0 def test_zero_canvas_defaults_center(self): x, y = resolve_sticker_position( "top_left", 50, 50, "pixel", "pixel", 0, 0, 0, 0, ) # canvas_w=0 → 回退到0.5 → 0*0.5=0 assert x == 0.0 assert y == 0.0 def test_sticker_size_zero(self): x, y = resolve_sticker_position( "center", None, None, "percent", "percent", self.CANVAS_W, self.CANVAS_H, 0, 0, ) assert x == pytest.approx(960.0) assert y == pytest.approx(540.0) # ============================================================ # parse_stickers_from_config # ============================================================ class TestParseStickersFromConfig: def test_none_returns_empty(self): assert parse_stickers_from_config(None) == [] def test_empty_dict_returns_empty(self): assert parse_stickers_from_config({}) == [] def test_stickers_list(self): config = {"stickers": [{"type": "text", "text": "hi"}, {"type": "image", "image_url": "a.png"}]} result = parse_stickers_from_config(config) assert len(result) == 2 assert result[0]["type"] == "text" def test_stickers_empty_list(self): config = {"stickers": []} assert parse_stickers_from_config(config) == [] def test_stickers_not_list_returns_empty(self): config = {"stickers": "not_a_list"} assert parse_stickers_from_config(config) == [] def test_other_config_ignored(self): config = {"other_field": "value"} assert parse_stickers_from_config(config) == [] # ============================================================ # get_sticker_categories # ============================================================ class TestGetStickerCategories: def test_returns_list(self): result = get_sticker_categories() assert isinstance(result, list) assert len(result) == len(STICKER_CATEGORIES) def test_returns_copy(self): # 修改返回值不应影响常量 result = get_sticker_categories() result.append(("new", "新分类")) assert len(STICKER_CATEGORIES) == len(STICKER_CATEGORIES) # 原常量不变 assert ("new", "新分类") not in STICKER_CATEGORIES def test_format(self): for cat in get_sticker_categories(): assert len(cat) == 2 assert isinstance(cat[0], str) assert isinstance(cat[1], str)