diff --git a/tests/unit/domain/test_watermark_config.py b/tests/unit/domain/test_watermark_config.py new file mode 100755 index 000000000..bb971416d --- /dev/null +++ b/tests/unit/domain/test_watermark_config.py @@ -0,0 +1,602 @@ +"""watermark_config 单元测试 - wave163 + +覆盖: +- WatermarkConfig 数据类 / from_dict / validate / has_effect +- calc_position 9宫格位置计算 +- calc_scroll_x 滚动计算 +- build_image_watermark_filter 图片水印滤镜 +- build_text_watermark_filter 文字水印滤镜 +- get_position_names / get_position_display_name +- 常量验证 +""" + +import pytest + +from packages.domain.watermark_config import ( + DEFAULT_FONT_COLOR, + DEFAULT_FONT_SIZE, + DEFAULT_MARGIN_X, + DEFAULT_MARGIN_Y, + DEFAULT_MODE, + DEFAULT_OPACITY, + DEFAULT_POSITION, + DEFAULT_SCALE, + DEFAULT_SCROLL_SPEED, + VALID_POSITIONS, + WATERMARK_POSITIONS, + WatermarkConfig, + build_image_watermark_filter, + build_text_watermark_filter, + calc_position, + calc_scroll_x, + get_position_display_name, + get_position_names, +) + +# ============================================================ +# 常量 +# ============================================================ + + +class TestConstants: + def test_nine_positions(self): + assert len(VALID_POSITIONS) == 9 + + def test_watermark_positions_contains_all(self): + assert set(WATERMARK_POSITIONS.keys()) == VALID_POSITIONS + + def test_default_position_valid(self): + assert DEFAULT_POSITION in VALID_POSITIONS + + def test_default_values(self): + assert DEFAULT_MODE in ("text", "image") + assert 0 < DEFAULT_SCALE <= 1.0 + assert 0 <= DEFAULT_OPACITY <= 1.0 + assert DEFAULT_FONT_SIZE > 0 + assert DEFAULT_MARGIN_X >= 0 + assert DEFAULT_MARGIN_Y >= 0 + assert DEFAULT_SCROLL_SPEED > 0 + assert isinstance(DEFAULT_FONT_COLOR, str) + + +# ============================================================ +# WatermarkConfig 默认值 +# ============================================================ + + +class TestWatermarkConfigDefaults: + def test_default_constructor(self): + config = WatermarkConfig() + assert config.mode == DEFAULT_MODE + assert config.position == DEFAULT_POSITION + assert config.scale == DEFAULT_SCALE + assert config.opacity == DEFAULT_OPACITY + assert config.font_size == DEFAULT_FONT_SIZE + assert config.font_color == DEFAULT_FONT_COLOR + assert config.margin_x == DEFAULT_MARGIN_X + assert config.margin_y == DEFAULT_MARGIN_Y + assert config.scroll is False + assert config.scroll_speed == DEFAULT_SCROLL_SPEED + + def test_text_mode_default(self): + config = WatermarkConfig() + assert config.mode == "text" + assert config.text == "" + assert config.font_path == "" + + def test_image_mode_default(self): + config = WatermarkConfig(mode="image") + assert config.image_path == "" + + +# ============================================================ +# from_dict +# ============================================================ + + +class TestFromDict: + def test_none_returns_none(self): + assert WatermarkConfig.from_dict(None) is None + + def test_empty_dict_returns_none(self): + assert WatermarkConfig.from_dict({}) is None + + def test_disabled_returns_none(self): + assert WatermarkConfig.from_dict({"enabled": False}) is None + + def test_image_mode_missing_path_returns_none(self): + result = WatermarkConfig.from_dict({"enabled": True, "mode": "image"}) + assert result is None + + def test_image_mode_with_image_field(self): + result = WatermarkConfig.from_dict({"enabled": True, "mode": "image", "image": "/path/to/wm.png"}) + assert result is not None + assert result.mode == "image" + assert result.image_path == "/path/to/wm.png" + + def test_image_mode_with_image_path_field(self): + result = WatermarkConfig.from_dict({"enabled": True, "mode": "image", "image_path": "/path/wm.png"}) + assert result is not None + assert result.image_path == "/path/wm.png" + + def test_text_mode_missing_text_returns_none(self): + result = WatermarkConfig.from_dict({"enabled": True, "mode": "text"}) + assert result is None + + def test_text_mode_with_text(self): + result = WatermarkConfig.from_dict({"enabled": True, "mode": "text", "text": "hello"}) + assert result is not None + assert result.mode == "text" + assert result.text == "hello" + + def test_invalid_position_falls_back_default(self): + result = WatermarkConfig.from_dict( + { + "enabled": True, + "mode": "text", + "text": "hello", + "position": "invalid_pos", + } + ) + assert result is not None + assert result.position == DEFAULT_POSITION + + def test_custom_values_propagated(self): + result = WatermarkConfig.from_dict( + { + "enabled": True, + "mode": "text", + "text": "测试", + "position": "top_left", + "opacity": 0.5, + "font_size": 32, + "font_color": "red", + "margin_x": 30, + "margin_y": 40, + "scroll": True, + "scroll_speed": 100, + } + ) + assert result is not None + assert result.position == "top_left" + assert result.opacity == 0.5 + assert result.font_size == 32 + assert result.font_color == "red" + assert result.margin_x == 30 + assert result.margin_y == 40 + assert result.scroll is True + assert result.scroll_speed == 100 + + def test_image_mode_custom_values(self): + result = WatermarkConfig.from_dict( + { + "enabled": True, + "mode": "image", + "image_path": "/wm.png", + "scale": 0.3, + "opacity": 0.6, + } + ) + assert result is not None + assert result.scale == 0.3 + assert result.opacity == 0.6 + + def test_default_mode_when_unspecified(self): + # 只给enabled和text,mode默认text + result = WatermarkConfig.from_dict({"enabled": True, "text": "hello"}) + assert result is not None + assert result.mode == DEFAULT_MODE + + def test_text_empty_string_returns_none(self): + result = WatermarkConfig.from_dict({"enabled": True, "mode": "text", "text": ""}) + assert result is None + + +# ============================================================ +# validate +# ============================================================ + + +class TestValidate: + def test_valid_text_config(self): + config = WatermarkConfig(mode="text", text="hello") + ok, msg = config.validate() + assert ok is True + assert msg == "" + + def test_valid_image_config(self): + config = WatermarkConfig(mode="image", image_path="/wm.png") + ok, msg = config.validate() + assert ok is True + assert msg == "" + + def test_invalid_position(self): + config = WatermarkConfig(mode="text", text="hi", position="nowhere") + ok, msg = config.validate() + assert ok is False + assert "位置" in msg + + def test_opacity_too_high(self): + config = WatermarkConfig(mode="text", text="hi", opacity=1.5) + ok, msg = config.validate() + assert ok is False + assert "透明度" in msg + + def test_opacity_negative(self): + config = WatermarkConfig(mode="text", text="hi", opacity=-0.1) + ok, msg = config.validate() + assert ok is False + assert "透明度" in msg + + def test_opacity_boundary_zero(self): + config = WatermarkConfig(mode="text", text="hi", opacity=0.0) + ok, _ = config.validate() + assert ok is True + + def test_opacity_boundary_one(self): + config = WatermarkConfig(mode="text", text="hi", opacity=1.0) + ok, _ = config.validate() + assert ok is True + + def test_image_missing_path(self): + config = WatermarkConfig(mode="image") + ok, msg = config.validate() + assert ok is False + assert "图片路径" in msg + + def test_image_scale_too_small(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scale=0.001) + ok, msg = config.validate() + assert ok is False + assert "缩放" in msg + + def test_image_scale_too_large(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scale=1.5) + ok, msg = config.validate() + assert ok is False + assert "缩放" in msg + + def test_image_scale_boundary_low(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scale=0.01) + ok, _ = config.validate() + assert ok is True + + def test_image_scale_boundary_high(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scale=1.0) + ok, _ = config.validate() + assert ok is True + + def test_text_empty(self): + config = WatermarkConfig(mode="text", text="") + ok, msg = config.validate() + assert ok is False + assert "文字内容" in msg + + def test_font_size_zero(self): + config = WatermarkConfig(mode="text", text="hi", font_size=0) + ok, msg = config.validate() + assert ok is False + assert "字体大小" in msg + + def test_font_size_negative(self): + config = WatermarkConfig(mode="text", text="hi", font_size=-5) + ok, msg = config.validate() + assert ok is False + assert "字体大小" in msg + + def test_unknown_mode(self): + config = WatermarkConfig(mode="video", text="hi") + ok, msg = config.validate() + assert ok is False + assert "模式" in msg + + +# ============================================================ +# has_effect +# ============================================================ + + +class TestHasEffect: + def test_text_has_effect(self): + config = WatermarkConfig(mode="text", text="hello", opacity=0.5, font_size=24) + assert config.has_effect() is True + + def test_text_empty_no_effect(self): + config = WatermarkConfig(mode="text", text="") + assert config.has_effect() is False + + def test_text_zero_opacity_no_effect(self): + config = WatermarkConfig(mode="text", text="hi", opacity=0.0) + assert config.has_effect() is False + + def test_text_zero_font_size_no_effect(self): + config = WatermarkConfig(mode="text", text="hi", font_size=0) + assert config.has_effect() is False + + def test_image_has_effect(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", opacity=0.5) + assert config.has_effect() is True + + def test_image_no_path_no_effect(self): + config = WatermarkConfig(mode="image") + assert config.has_effect() is False + + def test_image_zero_opacity_no_effect(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", opacity=0.0) + assert config.has_effect() is False + + def test_unknown_mode_no_effect(self): + config = WatermarkConfig(mode="invalid") + assert config.has_effect() is False + + +# ============================================================ +# calc_position +# ============================================================ + + +class TestCalcPosition: + OUT_W = 1920 + OUT_H = 1080 + WM_W = 200 + WM_H = 50 + MX = 20 + MY = 20 + + def test_top_left(self): + x, y = calc_position("top_left", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 20 + assert y == 20 + + def test_top_center(self): + x, y = calc_position("top_center", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == (1920 - 200) // 2 + assert y == 20 + + def test_top_right(self): + x, y = calc_position("top_right", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 1920 - 200 - 20 + assert y == 20 + + def test_center_left(self): + x, y = calc_position("center_left", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 20 + assert y == (1080 - 50) // 2 + + def test_center(self): + x, y = calc_position("center", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == (1920 - 200) // 2 + assert y == (1080 - 50) // 2 + + def test_center_right(self): + x, y = calc_position("center_right", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 1920 - 200 - 20 + assert y == (1080 - 50) // 2 + + def test_bottom_left(self): + x, y = calc_position("bottom_left", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 20 + assert y == 1080 - 50 - 20 + + def test_bottom_center(self): + x, y = calc_position("bottom_center", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == (1920 - 200) // 2 + assert y == 1080 - 50 - 20 + + def test_bottom_right(self): + x, y = calc_position("bottom_right", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 1920 - 200 - 20 + assert y == 1080 - 50 - 20 + + def test_invalid_position_defaults_bottom_right(self): + x, y = calc_position("invalid", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + assert x == 1920 - 200 - 20 + assert y == 1080 - 50 - 20 + + def test_zero_margins(self): + x, y = calc_position("top_left", self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, 0, 0) + assert x == 0 + assert y == 0 + + def test_all_nine_positions_are_unique(self): + positions = set() + for pos in VALID_POSITIONS: + pos_xy = calc_position(pos, self.OUT_W, self.OUT_H, self.WM_W, self.WM_H, self.MX, self.MY) + positions.add(pos_xy) + assert len(positions) == 9 + + +# ============================================================ +# calc_scroll_x +# ============================================================ + + +class TestCalcScrollX: + def test_returns_string(self): + result = calc_scroll_x("bottom_left", 1920, 200, 50) + assert isinstance(result, str) + + def test_contains_output_width(self): + result = calc_scroll_x("bottom_left", 1920, 200, 50) + assert "1920" in result + + def test_contains_wm_width(self): + result = calc_scroll_x("bottom_left", 1920, 200, 50) + assert "200" in result + + def test_contains_speed(self): + result = calc_scroll_x("bottom_left", 1920, 200, 50) + assert "50" in result + + def test_contains_mod_keyword(self): + result = calc_scroll_x("bottom_left", 1920, 200, 50) + assert "mod" in result + + +# ============================================================ +# build_image_watermark_filter +# ============================================================ + + +class TestBuildImageWatermarkFilter: + def test_returns_tuple(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", position="top_left") + result = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert isinstance(result, tuple) + assert len(result) == 2 + + def test_filter_contains_overlay(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", position="bottom_right") + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert "overlay" in filter_str + + def test_filter_contains_scale(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scale=0.3) + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert "scale=" in filter_str + + def test_opacity_applied_when_below_one(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", opacity=0.5) + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert "colorchannelmixer" in filter_str + assert "aa=0.5" in filter_str + + def test_opacity_one_no_mixer(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", opacity=1.0) + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert "colorchannelmixer" not in filter_str + + def test_input_args_contain_image_path(self): + config = WatermarkConfig(mode="image", image_path="/path/to/wm.png") + _, input_args = build_image_watermark_filter("[in]", "/path/to/wm.png", 1920, 1080, "[out]", config) + assert input_args == ["-i", "/path/to/wm.png"] + + def test_output_label_present(self): + config = WatermarkConfig(mode="image", image_path="/wm.png") + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[final]", config) + assert "[final]" in filter_str + + def test_scroll_mode_contains_t_variable(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scroll=True, scroll_speed=60) + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert "*t" in filter_str or "t*" in filter_str + + def test_static_mode_no_t_variable_in_overlay_x(self): + config = WatermarkConfig(mode="image", image_path="/wm.png", scroll=False) + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + # 静态模式 x 是具体数值,不包含 t 变量 + overlay_part = filter_str.split("overlay=")[1] + assert "t" not in overlay_part.split(":")[0] or "wm" in overlay_part.split(":")[0] + + def test_semicolon_separated_filters(self): + config = WatermarkConfig(mode="image", image_path="/wm.png") + filter_str, _ = build_image_watermark_filter("[in]", "/wm.png", 1920, 1080, "[out]", config) + assert ";" in filter_str + + +# ============================================================ +# build_text_watermark_filter +# ============================================================ + + +class TestBuildTextWatermarkFilter: + def test_returns_string(self): + config = WatermarkConfig(mode="text", text="hello") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert isinstance(result, str) + + def test_contains_drawtext(self): + config = WatermarkConfig(mode="text", text="hello") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "drawtext=" in result + + def test_contains_text(self): + config = WatermarkConfig(mode="text", text="测试水印") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "测试水印" in result + + def test_fontsize_in_filter(self): + config = WatermarkConfig(mode="text", text="hi", font_size=36) + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "fontsize=36" in result + + def test_fontcolor_with_opacity(self): + config = WatermarkConfig(mode="text", text="hi", font_color="red", opacity=0.5) + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "fontcolor=red@0.5" in result + + def test_font_path_included(self): + config = WatermarkConfig(mode="text", text="hi", font_path="/fonts/simsun.ttf") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "fontfile=" in result + assert "simsun.ttf" in result + + def test_no_font_path_when_empty(self): + config = WatermarkConfig(mode="text", text="hi", font_path="") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "fontfile=" not in result + + def test_text_colon_escaped(self): + config = WatermarkConfig(mode="text", text="time: 00:00") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + # 冒号应该被转义 + assert "time\\:" in result or "time\\\\:" in result + + def test_scroll_mode_contains_mod(self): + config = WatermarkConfig(mode="text", text="scrolling", scroll=True, scroll_speed=50) + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "mod(" in result + + def test_static_mode_has_numeric_x_y(self): + config = WatermarkConfig(mode="text", text="hi", position="top_left") + result = build_text_watermark_filter("[in]", "[out]", config, 1920, 1080) + assert "x=" in result + assert "y=" in result + + def test_output_label_present(self): + config = WatermarkConfig(mode="text", text="hi") + result = build_text_watermark_filter("[in]", "[final_out]", config, 1920, 1080) + assert "[final_out]" in result + + def test_input_label_present(self): + config = WatermarkConfig(mode="text", text="hi") + result = build_text_watermark_filter("[video_in]", "[out]", config, 1920, 1080) + assert result.startswith("[video_in]") + + +# ============================================================ +# 工具函数 +# ============================================================ + + +class TestGetPositionNames: + def test_returns_nine_names(self): + names = get_position_names() + assert len(names) == 9 + + def test_order_is_correct(self): + names = get_position_names() + # 按从上到下、从左到右 + assert names[0] == "top_left" + assert names[1] == "top_center" + assert names[2] == "top_right" + assert names[3] == "center_left" + assert names[4] == "center" + assert names[5] == "center_right" + assert names[6] == "bottom_left" + assert names[7] == "bottom_center" + assert names[8] == "bottom_right" + + def test_all_valid(self): + names = get_position_names() + assert set(names) == VALID_POSITIONS + + +class TestGetPositionDisplayName: + def test_known_position(self): + assert get_position_display_name("top_left") == "左上" + assert get_position_display_name("center") == "中心" + assert get_position_display_name("bottom_right") == "右下" + + def test_unknown_position_returns_original(self): + assert get_position_display_name("invalid") == "invalid"