Files
xiaoxia-saas/tests/unit/test_watermark_intro_outro.py
T
xiaoxia 241760ef39
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 37s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 4m21s
CI/CD Pipeline / Integration Tests (push) Failing after 4m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
feat: 水印 + 片头片尾引擎(视频包装能力) (#298)
2026-07-14 10:44:19 +08:00

345 lines
11 KiB
Python
Executable File

"""水印 + 片头片尾引擎单元测试."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
from video_processing.intro_outro_engine import (
IntroOutroConfig,
IntroOutroEngine,
)
from video_processing.watermark_engine import (
WATERMARK_POSITIONS,
WatermarkConfig,
WatermarkEngine,
)
class TestWatermarkConfig(unittest.TestCase):
"""WatermarkConfig 单元测试."""
def test_from_dict_none_disabled(self):
"""空配置或未启用 → None."""
self.assertIsNone(WatermarkConfig.from_dict(None))
self.assertIsNone(WatermarkConfig.from_dict({}))
self.assertIsNone(WatermarkConfig.from_dict({"enabled": False}))
def test_from_dict_text_mode(self):
"""文字水印模式."""
cfg = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "hello world",
"position": "top_left",
})
self.assertIsNotNone(cfg)
self.assertEqual(cfg.mode, "text")
self.assertEqual(cfg.text, "hello world")
self.assertEqual(cfg.position, "top_left")
def test_from_dict_image_missing_path(self):
"""图片水印缺路径 → None."""
cfg = WatermarkConfig.from_dict({
"enabled": True,
"mode": "image",
})
self.assertIsNone(cfg)
def test_from_dict_text_missing_text(self):
"""文字水印缺文字 → None."""
cfg = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
})
self.assertIsNone(cfg)
def test_validate_text_valid(self):
"""文字水印合法配置."""
cfg = WatermarkConfig(
mode="text",
text="test",
position="bottom_right",
)
ok, err = cfg.validate()
self.assertTrue(ok)
self.assertEqual(err, "")
def test_validate_invalid_position(self):
"""非法位置."""
cfg = WatermarkConfig(mode="text", text="test", position="invalid")
ok, err = cfg.validate()
self.assertFalse(ok)
self.assertIn("不支持的位置", err)
def test_validate_opacity_out_of_range(self):
"""透明度超范围."""
cfg = WatermarkConfig(mode="text", text="test", opacity=1.5)
ok, err = cfg.validate()
self.assertFalse(ok)
def test_validate_image_missing_path(self):
"""图片水印缺路径."""
cfg = WatermarkConfig(mode="image")
ok, err = cfg.validate()
self.assertFalse(ok)
class TestWatermarkEnginePosition(unittest.TestCase):
"""水印位置计算单元测试."""
def setUp(self):
self.out_w = 1920
self.out_h = 1080
self.wm_w = 200
self.wm_h = 100
self.mx = 20
self.my = 20
def test_top_left(self):
x, y = WatermarkEngine.calc_position(
"top_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 20)
self.assertEqual(y, 20)
def test_top_center(self):
x, y = WatermarkEngine.calc_position(
"top_center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, (1920 - 200) // 2)
self.assertEqual(y, 20)
def test_top_right(self):
x, y = WatermarkEngine.calc_position(
"top_right", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 1920 - 200 - 20)
self.assertEqual(y, 20)
def test_center_left(self):
x, y = WatermarkEngine.calc_position(
"center_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 20)
self.assertEqual(y, (1080 - 100) // 2)
def test_center(self):
x, y = WatermarkEngine.calc_position(
"center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, (1920 - 200) // 2)
self.assertEqual(y, (1080 - 100) // 2)
def test_center_right(self):
x, y = WatermarkEngine.calc_position(
"center_right", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 1920 - 200 - 20)
self.assertEqual(y, (1080 - 100) // 2)
def test_bottom_left(self):
x, y = WatermarkEngine.calc_position(
"bottom_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 20)
self.assertEqual(y, 1080 - 100 - 20)
def test_bottom_center(self):
x, y = WatermarkEngine.calc_position(
"bottom_center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, (1920 - 200) // 2)
self.assertEqual(y, 1080 - 100 - 20)
def test_bottom_right(self):
x, y = WatermarkEngine.calc_position(
"bottom_right", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 1920 - 200 - 20)
self.assertEqual(y, 1080 - 100 - 20)
def test_default_fallback(self):
"""非法位置默认右下角."""
x, y = WatermarkEngine.calc_position(
"unknown", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
self.assertEqual(x, 1920 - 200 - 20)
self.assertEqual(y, 1080 - 100 - 20)
def test_nine_positions_all_present(self):
"""9宫格位置都有定义."""
self.assertEqual(len(WATERMARK_POSITIONS), 9)
class TestWatermarkEngineFilters(unittest.TestCase):
"""水印滤镜构建单元测试."""
def test_text_watermark_filter(self):
"""文字水印滤镜构建."""
cfg = WatermarkConfig(
mode="text",
text="hello",
position="top_left",
font_size=24,
font_color="white",
opacity=0.8,
margin_x=10,
margin_y=10,
)
result = WatermarkEngine.build_text_watermark_filter(
"[in]", "[out]", cfg, 1920, 1080
)
self.assertTrue(result.startswith("[in]drawtext="))
self.assertIn("text='hello'", result)
self.assertIn("fontsize=24", result)
self.assertIn("fontcolor=white@0.8", result)
self.assertTrue(result.endswith("[out]"))
def test_text_watermark_scroll(self):
"""滚动文字水印."""
cfg = WatermarkConfig(
mode="text",
text="scroll",
position="bottom_left",
scroll=True,
scroll_speed=60,
)
result = WatermarkEngine.build_text_watermark_filter(
"[in]", "[out]", cfg, 1920, 1080
)
self.assertIn("mod(60*t", result)
class TestIntroOutroConfig(unittest.TestCase):
"""IntroOutroConfig 单元测试."""
def test_from_dict_disabled(self):
"""未启用 → 空配置."""
cfg = IntroOutroConfig.from_dict(None)
self.assertFalse(cfg.enabled)
self.assertFalse(cfg.has_intro)
self.assertFalse(cfg.has_outro)
def test_from_dict_intro_text(self):
"""文字片头配置."""
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "text",
"title": "欢迎观看",
"subtitle": "精彩内容马上开始",
"duration": 3.0,
"background": "#1a1a2e",
},
})
self.assertTrue(cfg.enabled)
self.assertTrue(cfg.has_intro)
self.assertFalse(cfg.has_outro)
self.assertEqual(cfg.intro_type, "text")
self.assertEqual(cfg.intro_title, "欢迎观看")
self.assertEqual(cfg.intro_duration, 3.0)
def test_from_dict_outro_video(self):
"""视频片尾配置."""
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 5.0,
},
})
self.assertTrue(cfg.has_outro)
self.assertEqual(cfg.outro_type, "video")
self.assertEqual(cfg.outro_video_path, "/tmp/outro.mp4")
def test_validate_valid(self):
"""合法配置."""
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_title="标题",
intro_duration=3.0,
outro_type="text",
outro_title="片尾",
outro_duration=3.0,
)
ok, err = cfg.validate()
self.assertTrue(ok)
def test_validate_video_intro_missing_path(self):
"""视频片头缺路径."""
cfg = IntroOutroConfig(
enabled=True,
intro_type="video",
intro_duration=3.0,
)
ok, err = cfg.validate()
self.assertFalse(ok)
self.assertIn("video_path", err)
def test_validate_text_intro_missing_title(self):
"""文字片头缺标题."""
cfg = IntroOutroConfig(
enabled=True,
intro_type="text",
intro_duration=3.0,
)
ok, err = cfg.validate()
self.assertFalse(ok)
def test_has_intro_false_when_none(self):
"""type=none 时 has_intro 为 False."""
cfg = IntroOutroConfig(enabled=True, intro_type="none")
self.assertFalse(cfg.has_intro)
def test_has_outro_follow_type(self):
"""follow 类型也算有片尾."""
cfg = IntroOutroConfig(enabled=True, outro_type="follow", outro_title="关注")
self.assertTrue(cfg.has_outro)
class TestIntroOutroEngineConcat(unittest.TestCase):
"""片头片尾拼接单元测试."""
def test_concat_no_intro_outro(self):
"""没有片头片尾 → 直接复制."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
main_video = Path(tmpdir) / "main.mp4"
output = Path(tmpdir) / "output.mp4"
# 创建空文件模拟
main_video.write_bytes(b"fake video data")
result = IntroOutroEngine.concat_with_intro_outro(
main_video, None, None, output
)
self.assertTrue(result)
self.assertTrue(output.exists())
self.assertEqual(main_video.read_bytes(), output.read_bytes())
def test_concat_intro_only_no_file(self):
"""只有片头但文件不存在 → 直接复制主视频."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
main_video = Path(tmpdir) / "main.mp4"
output = Path(tmpdir) / "output.mp4"
main_video.write_bytes(b"fake data")
# intro 路径不存在
intro = Path(tmpdir) / "nonexistent.mp4"
result = IntroOutroEngine.concat_with_intro_outro(
main_video, intro, None, output
)
self.assertTrue(result)
self.assertTrue(output.exists())
if __name__ == "__main__":
unittest.main()