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