From f8de295b6d90801a9286007446df10565e30a38d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 14:25:52 +0800 Subject: [PATCH] =?UTF-8?q?test(wave167):=20sticker=5Fconfig=E8=B4=B4?= =?UTF-8?q?=E7=BA=B8=E9=85=8D=E7=BD=AE=20+91=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 常量验证:9宫格预设/归一化范围/角落位置/中心点/贴纸分类 - ImageStickerConfig默认值:所有字段默认值/has_time_range/end_time - ImageSticker.from_dict:None/空/非dict/enabled/position/自定义坐标 字符串坐标/非法坐标→None/scale钳制/opacity钳制/start_time/duration fade_in/fade_out/z_index/width_height/image_url_preset/units/非法数值回退 - TextStickerConfig默认值:所有字段/has_background/has_time_range - TextSticker.from_dict:None/空/非dict/text/font_size最小1/font_color stroke_width/shadow_alpha钳制/shadow偏移/position/坐标/时间 fade/z_index/bg系列/非法数值回退 - StickerOverlayResult:最小创建/带extra_inputs/列表独立 - resolve_sticker_position:9宫格预设/非法预设默认居中/自定义percent percent钳制0和100/pixel坐标/x-only/y-only/画布钳制/左上钳制/零画布/零贴纸 - parse_stickers_from_config:None/空/正常列表/空列表/非列表/其他字段 - get_sticker_categories:返回列表/返回拷贝/格式 --- tests/unit/domain/test_sticker_config.py | 675 +++++++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100755 tests/unit/domain/test_sticker_config.py diff --git a/tests/unit/domain/test_sticker_config.py b/tests/unit/domain/test_sticker_config.py new file mode 100755 index 000000000..346619f88 --- /dev/null +++ b/tests/unit/domain/test_sticker_config.py @@ -0,0 +1,675 @@ +"""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) -- 2.54.0