Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 71967c092b style: auto-format with black + isort + prettier [skip ci-format-check] 2026-07-28 09:02:54 +00:00
xiaoxia 01750d4d9d test(wave163): watermark_config水印配置 +88测
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
PR Automation / Auto Approve on CI Green (pull_request) Waiting to run
PR Automation / Auto Merge on CI Green + Approved (pull_request) Waiting to run
Preview Deploy / Deploy Preview Environment (pull_request) Waiting to run
AI Code Review / AI Code Review (pull_request) CI runner不可用,手动设置
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Build Production API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Worker Image (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m27s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m31s
CI/CD Pipeline / Integration Tests (pull_request) Waiting to run
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m52s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Waiting to run
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 9m49s
- 常量验证:9宫格位置/默认值
- WatermarkConfig默认值:text/image模式
- from_dict构造:None/空/enabled缺省/缺字段降级/非法位置回退/自定义值
- validate校验:有效配置/非法位置/透明度边界/图片scale边界/文字空值/字体大小/未知模式
- has_effect效果判断:各种有效/无效组合
- calc_position位置计算:9宫格全覆盖+非法位置默认+零边距+唯一性
- calc_scroll_x滚动计算:返回格式/参数包含
- build_image_watermark_filter:overlay/scale/透明度/输入参数/输出标签/滚动模式/静态模式
- build_text_watermark_filter:drawtext/文字内容/字体配置/路径/转义/滚动/静态/标签
- 工具函数:get_position_names/get_position_display_name
2026-07-28 16:06:24 +08:00
5 changed files with 602 additions and 4 deletions
@@ -6,7 +6,6 @@
from __future__ import annotations
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -13,7 +13,6 @@
from __future__ import annotations
from packages.domain.speed_config import (
DEFAULT_SPEED,
SpeedConfig,
@@ -6,7 +6,6 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
from __future__ import annotations
import dataclasses
from unittest.mock import MagicMock
import pytest
+602
View File
@@ -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和textmode默认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"
-1
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import dataclasses
import pytest
from packages.domain.transition_presets import (