style: auto-format with black + isort + prettier
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m18s
CI/CD Pipeline / Unit Tests (push) Failing after 4m59s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 27m2s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 30m15s

This commit is contained in:
CI Bot
2026-07-25 00:16:10 +00:00
parent b82b303d6a
commit 7a72cfd709
23 changed files with 788 additions and 625 deletions
-1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import os
import pytest
from services.asr_service_factory import get_asr_service, reset_asr_service_cache
+31 -23
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.bgm_mixer import BGMConfig
@@ -43,10 +42,13 @@ class TestBGMConfigFromConfigDict:
def test_fade_in_out(self):
"""淡入淡出."""
config = BGMConfig.from_config_dict("/a.mp3", {
"fade_in": 2.0,
"fade_out": 3.0,
})
config = BGMConfig.from_config_dict(
"/a.mp3",
{
"fade_in": 2.0,
"fade_out": 3.0,
},
)
assert config.fade_in == 2.0
assert config.fade_out == 3.0
@@ -62,13 +64,16 @@ class TestBGMConfigFromConfigDict:
def test_sidechain_custom_params(self):
"""闪避自定义参数."""
config = BGMConfig.from_config_dict("/a.mp3", {
"sidechain_enabled": True,
"sidechain_ratio": 0.5,
"sidechain_attack": 0.05,
"sidechain_release": 0.8,
"sidechain_threshold": -30.0,
})
config = BGMConfig.from_config_dict(
"/a.mp3",
{
"sidechain_enabled": True,
"sidechain_ratio": 0.5,
"sidechain_attack": 0.05,
"sidechain_release": 0.8,
"sidechain_threshold": -30.0,
},
)
assert config.sidechain_ratio == 0.5
assert config.sidechain_attack == 0.05
assert config.sidechain_release == 0.8
@@ -81,17 +86,20 @@ class TestBGMConfigFromConfigDict:
def test_all_params_custom(self):
"""所有参数自定义."""
config = BGMConfig.from_config_dict("/full.mp3", {
"volume": 0.7,
"fade_in": 1.5,
"fade_out": 2.0,
"loop_enabled": False,
"sidechain_enabled": True,
"sidechain_ratio": 0.4,
"sidechain_attack": 0.03,
"sidechain_release": 0.6,
"sidechain_threshold": -20.0,
})
config = BGMConfig.from_config_dict(
"/full.mp3",
{
"volume": 0.7,
"fade_in": 1.5,
"fade_out": 2.0,
"loop_enabled": False,
"sidechain_enabled": True,
"sidechain_ratio": 0.4,
"sidechain_attack": 0.03,
"sidechain_release": 0.6,
"sidechain_threshold": -20.0,
},
)
assert config.volume == 0.7
assert config.fade_in == 1.5
assert config.fade_out == 2.0
+69 -48
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.chroma_key_engine import (
CHROMA_KEY_PRESETS,
ChromaKeyConfig,
@@ -52,93 +51,115 @@ class TestChromaKeyConfigFromDict:
def test_custom_key_color(self):
"""自定义抠像颜色."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"key_color": "#0000FF",
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"key_color": "#0000FF",
}
)
assert config.key_color == "#0000FF"
def test_similarity_parsed(self):
"""相似度解析."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": 0.5,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"similarity": 0.5,
}
)
assert config.similarity == 0.5
def test_similarity_clamped_min(self):
"""相似度下限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": 0.001,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"similarity": 0.001,
}
)
assert config.similarity == 0.01
def test_similarity_clamped_max(self):
"""相似度上限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": 2.0,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"similarity": 2.0,
}
)
assert config.similarity == 1.0
def test_blend_clamped_min(self):
"""混合度下限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"blend": -0.5,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"blend": -0.5,
}
)
assert config.blend == 0.0
def test_blend_clamped_max(self):
"""混合度上限钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"blend": 1.5,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"blend": 1.5,
}
)
assert config.blend == 1.0
def test_spill_suppress_clamped(self):
"""溢色抑制钳制."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"spill_suppress": 2.0,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"spill_suppress": 2.0,
}
)
assert config.spill_suppress == 1.0
def test_invalid_similarity_falls_back(self):
"""无效相似度回退到默认."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"similarity": "not_a_number",
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"similarity": "not_a_number",
}
)
assert config.similarity == 0.3
def test_invalid_blend_falls_back(self):
"""无效混合度回退."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"blend": "high",
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"blend": "high",
}
)
assert config.blend == 0.1
def test_key_color_stripped(self):
"""颜色值去除首尾空格."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"key_color": " #FF0000 ",
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"key_color": " #FF0000 ",
}
)
assert config.key_color == "#FF0000"
def test_all_params_custom(self):
"""所有参数自定义."""
config = ChromaKeyConfig.from_dict({
"enabled": True,
"key_color": "#0000FF",
"similarity": 0.45,
"blend": 0.15,
"spill_suppress": 0.6,
})
config = ChromaKeyConfig.from_dict(
{
"enabled": True,
"key_color": "#0000FF",
"similarity": 0.45,
"blend": 0.15,
"spill_suppress": 0.6,
}
)
assert config.enabled is True
assert config.key_color == "#0000FF"
assert config.similarity == 0.45
+49 -40
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.color_grade_engine import (
DEFAULT_PARAMS,
PARAM_RANGES,
@@ -55,39 +54,47 @@ class TestColorGradeConfigFromDict:
def test_with_preset(self):
"""指定预设."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"preset": "fresh",
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"preset": "fresh",
}
)
assert config.enabled is True
assert config.preset == "fresh"
def test_invalid_preset_ignored(self):
"""无效预设被忽略."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"preset": "unknown_preset",
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"preset": "unknown_preset",
}
)
assert config.preset == ""
def test_custom_brightness(self):
"""自定义亮度."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"brightness": 20,
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": 20,
}
)
assert config.brightness == 20.0
def test_custom_all_params(self):
"""所有参数自定义."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"brightness": 10,
"contrast": 15,
"saturation": 120,
"temperature": -5,
"hue": 10,
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": 10,
"contrast": 15,
"saturation": 120,
"temperature": -5,
"hue": 10,
}
)
assert config.brightness == 10.0
assert config.contrast == 15.0
assert config.saturation == 120.0
@@ -96,27 +103,33 @@ class TestColorGradeConfigFromDict:
def test_invalid_param_value_returns_none(self):
"""无效参数值返回None(不覆盖)."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"brightness": "not_a_number",
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": "not_a_number",
}
)
assert config.brightness is None
def test_null_param_returns_none(self):
"""null参数值返回None."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"contrast": None,
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"contrast": None,
}
)
assert config.contrast is None
def test_preset_with_custom_override(self):
"""预设 + 自定义覆盖."""
config = ColorGradeConfig.from_dict({
"enabled": True,
"preset": "vintage",
"brightness": 5,
})
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"preset": "vintage",
"brightness": 5,
}
)
assert config.preset == "vintage"
assert config.brightness == 5.0
@@ -192,9 +205,7 @@ class TestResolveParams:
"""返回所有5个参数."""
config = ColorGradeConfig(enabled=True)
params = config.resolve_params()
assert set(params.keys()) == {
"brightness", "contrast", "saturation", "temperature", "hue"
}
assert set(params.keys()) == {"brightness", "contrast", "saturation", "temperature", "hue"}
class TestHasEffect:
@@ -247,6 +258,4 @@ class TestPresets:
def test_param_ranges_defined(self):
"""参数范围定义完整."""
assert set(PARAM_RANGES.keys()) == {
"brightness", "contrast", "saturation", "temperature", "hue"
}
assert set(PARAM_RANGES.keys()) == {"brightness", "contrast", "saturation", "temperature", "hue"}
+136 -95
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.concat_engine import ConcatConfig, ConcatSegment
@@ -31,68 +30,84 @@ class TestConcatSegmentFromDict:
def test_custom_start_time(self):
"""自定义开始时间."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"start_time": 5.0,
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"start_time": 5.0,
}
)
assert seg.start_time == 5.0
def test_custom_duration(self):
"""自定义时长."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"duration": 10.0,
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"duration": 10.0,
}
)
assert seg.duration == 10.0
def test_start_time_negative_clamped(self):
"""负开始时间钳制到0."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"start_time": -5.0,
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"start_time": -5.0,
}
)
assert seg.start_time == 0.0
def test_duration_negative_clamped(self):
"""负时长钳制到0."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"duration": -3.0,
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"duration": -3.0,
}
)
assert seg.duration == 0.0
def test_invalid_start_time_falls_back(self):
"""无效start_time回退到0."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"start_time": "invalid",
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"start_time": "invalid",
}
)
assert seg.start_time == 0.0
def test_invalid_duration_falls_back(self):
"""无效duration回退到0."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"duration": "not_a_number",
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"duration": "not_a_number",
}
)
assert seg.duration == 0.0
def test_no_audio(self):
"""无音频."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"has_audio": False,
})
seg = ConcatSegment.from_dict(
{
"video_path": "/a.mp4",
"has_audio": False,
}
)
assert seg.has_audio is False
def test_full_config(self):
"""完整配置."""
seg = ConcatSegment.from_dict({
"video_path": "/video.mp4",
"start_time": 2.5,
"duration": 15.0,
"has_audio": False,
})
seg = ConcatSegment.from_dict(
{
"video_path": "/video.mp4",
"start_time": 2.5,
"duration": 15.0,
"has_audio": False,
}
)
assert seg.video_path == "/video.mp4"
assert seg.start_time == 2.5
assert seg.duration == 15.0
@@ -129,96 +144,116 @@ class TestConcatConfigFromConfigDict:
def test_single_segment(self):
"""单片段."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
}
)
assert len(config.segments) == 1
assert config.segments[0].video_path == "/a.mp4"
def test_multiple_segments(self):
"""多片段."""
config = ConcatConfig.from_config_dict({
"segments": [
{"video_path": "/a.mp4", "start_time": 1.0},
{"video_path": "/b.mp4", "duration": 5.0},
{"video_path": "/c.mp4"},
],
})
config = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4", "start_time": 1.0},
{"video_path": "/b.mp4", "duration": 5.0},
{"video_path": "/c.mp4"},
],
}
)
assert len(config.segments) == 3
assert config.segments[0].start_time == 1.0
assert config.segments[1].duration == 5.0
def test_skips_no_path(self):
"""跳过无video_path的片段."""
config = ConcatConfig.from_config_dict({
"segments": [
{"video_path": "/a.mp4"},
{"other": "value"},
{"video_path": ""},
],
})
config = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/a.mp4"},
{"other": "value"},
{"video_path": ""},
],
}
)
assert len(config.segments) == 1
def test_segments_not_list_ignored(self):
"""segments不是列表忽略."""
config = ConcatConfig.from_config_dict({
"segments": "not_a_list",
})
config = ConcatConfig.from_config_dict(
{
"segments": "not_a_list",
}
)
assert config.segments == []
def test_output_size(self):
"""输出尺寸."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
"output_width": 1920,
"output_height": 1080,
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"output_width": 1920,
"output_height": 1080,
}
)
assert config.output_width == 1920
assert config.output_height == 1080
def test_negative_output_size_clamped(self):
"""负输出尺寸钳制到0."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
"output_width": -100,
"output_height": -50,
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"output_width": -100,
"output_height": -50,
}
)
assert config.output_width == 0
assert config.output_height == 0
def test_invalid_output_size_falls_back(self):
"""无效输出尺寸回退."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
"output_width": "wide",
"output_fps": "sixty",
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"output_width": "wide",
"output_fps": "sixty",
}
)
assert config.output_width == 0
assert config.output_fps == 0.0
def test_output_fps(self):
"""输出帧率."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
"output_fps": 60.0,
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"output_fps": 60.0,
}
)
assert config.output_fps == 60.0
def test_force_reencode(self):
"""强制重新编码."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
"force_reencode": True,
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}],
"force_reencode": True,
}
)
assert config.force_reencode is True
def test_transition_config(self):
"""转场配置."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
"transition": "crossfade",
"transition_duration": 1.0,
})
config = ConcatConfig.from_config_dict(
{
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
"transition": "crossfade",
"transition_duration": 1.0,
}
)
assert config.transition == "crossfade"
assert config.transition_duration == 1.0
@@ -238,17 +273,21 @@ class TestHasEffect:
def test_one_segment_no_effect(self):
"""单片段无效果(拼接至少需要2段)."""
config = ConcatConfig(segments=[
ConcatSegment(video_path="/a.mp4"),
])
config = ConcatConfig(
segments=[
ConcatSegment(video_path="/a.mp4"),
]
)
assert config.has_effect is False
def test_two_segments_has_effect(self):
"""两段及以上有效果."""
config = ConcatConfig(segments=[
ConcatSegment(video_path="/a.mp4"),
ConcatSegment(video_path="/b.mp4"),
])
config = ConcatConfig(
segments=[
ConcatSegment(video_path="/a.mp4"),
ConcatSegment(video_path="/b.mp4"),
]
)
assert config.has_effect is True
@@ -262,9 +301,11 @@ class TestTotalSegments:
def test_three_segments(self):
"""三个片段."""
config = ConcatConfig(segments=[
ConcatSegment(video_path="/a.mp4"),
ConcatSegment(video_path="/b.mp4"),
ConcatSegment(video_path="/c.mp4"),
])
config = ConcatConfig(
segments=[
ConcatSegment(video_path="/a.mp4"),
ConcatSegment(video_path="/b.mp4"),
ConcatSegment(video_path="/c.mp4"),
]
)
assert config.total_segments == 3
+2 -5
View File
@@ -3,12 +3,11 @@
from __future__ import annotations
import pytest
from video_processing.ffmpeg_utils import (
XFADE_TRANSITION_MAP,
build_xfade_filter_chain,
chain_filters,
resolve_xfade_transition,
build_xfade_filter_chain,
)
@@ -97,9 +96,7 @@ class TestBuildXfadeFilterChain:
def test_single_clip(self):
"""1个片段→直接copy,总时长等于片段时长."""
filter_str, total_dur = build_xfade_filter_chain(
[10.0], ["v0"], [], output_label="outv"
)
filter_str, total_dur = build_xfade_filter_chain([10.0], ["v0"], [], output_label="outv")
assert "[v0]copy[outv]" in filter_str
assert total_dur == pytest.approx(10.0)
-1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.intro_outro_engine import IntroOutroConfig
+33 -23
View File
@@ -229,10 +229,12 @@ class TestModuleRegistryCapabilities:
def test_has_capability_true(self):
"""检查已存在的能力."""
registry = ModuleRegistry()
registry.register(Module(
name="ai_mod",
capabilities=[ModuleCapability(name="generate_voice")],
))
registry.register(
Module(
name="ai_mod",
capabilities=[ModuleCapability(name="generate_voice")],
)
)
assert registry.has_capability("generate_voice") is True
def test_has_capability_false(self):
@@ -270,10 +272,12 @@ class TestModuleRegistryCapabilities:
def test_get_quota_rules_empty(self):
"""没有配额规则时返回空列表."""
registry = ModuleRegistry()
registry.register(Module(
name="m1",
capabilities=[ModuleCapability(name="do_something")],
))
registry.register(
Module(
name="m1",
capabilities=[ModuleCapability(name="do_something")],
)
)
rules = registry.get_quota_rules("do_something")
assert rules == []
@@ -281,10 +285,12 @@ class TestModuleRegistryCapabilities:
"""获取配额规则."""
registry = ModuleRegistry()
rules = [QuotaRule("credits", 2.0)]
registry.register(Module(
name="m1",
capabilities=[ModuleCapability(name="do_something", quota_rules=rules)],
))
registry.register(
Module(
name="m1",
capabilities=[ModuleCapability(name="do_something", quota_rules=rules)],
)
)
result = registry.get_quota_rules("do_something")
assert len(result) == 1
assert result[0].dimension == "credits"
@@ -293,17 +299,21 @@ class TestModuleRegistryCapabilities:
def test_get_active_capabilities(self):
"""获取所有已激活模块的能力."""
registry = ModuleRegistry()
registry.register(Module(
name="mod_a",
capabilities=[
ModuleCapability(name="cap_a1"),
ModuleCapability(name="cap_a2"),
],
))
registry.register(Module(
name="mod_b",
capabilities=[ModuleCapability(name="cap_b1")],
))
registry.register(
Module(
name="mod_a",
capabilities=[
ModuleCapability(name="cap_a1"),
ModuleCapability(name="cap_a2"),
],
)
)
registry.register(
Module(
name="mod_b",
capabilities=[ModuleCapability(name="cap_b1")],
)
)
result = registry.get_active_capabilities()
assert "mod_a" in result
assert "mod_b" in result
+179 -130
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.multi_track_mixer import (
DEFAULT_VOLUMES,
MAX_AUDIO_TRACKS,
@@ -64,135 +63,163 @@ class TestAudioTrackFromDict:
def test_basic_parsing(self):
"""基本解析."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/bgm.mp3",
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/bgm.mp3",
}
)
assert track.track_id == "t1"
assert track.track_type == "bgm"
assert track.audio_path == "/bgm.mp3"
def test_default_volume_by_type_bgm(self):
"""bgm默认音量0.3."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
}
)
assert track.volume == 0.3
def test_default_volume_by_type_sfx(self):
"""sfx默认音量0.7."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "sfx",
"audio_path": "/a.mp3",
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "sfx",
"audio_path": "/a.mp3",
}
)
assert track.volume == 0.7
def test_default_volume_unknown_type(self):
"""未知类型默认音量1.0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "unknown_type",
"audio_path": "/a.mp3",
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "unknown_type",
"audio_path": "/a.mp3",
}
)
assert track.volume == 1.0
def test_custom_volume(self):
"""自定义音量."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 0.5,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 0.5,
}
)
assert track.volume == 0.5
def test_volume_clamped_high(self):
"""音量上限钳制."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 3.0,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": 3.0,
}
)
assert track.volume == 2.0
def test_volume_clamped_low(self):
"""音量下限钳制."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"volume": -1.0,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"volume": -1.0,
}
)
assert track.volume == 0.0
def test_volume_invalid_falls_back(self):
"""无效音量回退到类型默认值."""
track = AudioTrack.from_dict({
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": "not_a_number",
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"track_type": "bgm",
"audio_path": "/a.mp3",
"volume": "not_a_number",
}
)
assert track.volume == 0.3
def test_fade_in(self):
"""淡入时长."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": 2.5,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": 2.5,
}
)
assert track.fade_in == 2.5
def test_fade_negative_clamped(self):
"""负淡入钳制到0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": -1.0,
"fade_out": -2.0,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": -1.0,
"fade_out": -2.0,
}
)
assert track.fade_in == 0.0
assert track.fade_out == 0.0
def test_start_time(self):
"""开始时间."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"start_time": 5.5,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"start_time": 5.5,
}
)
assert track.start_time == 5.5
def test_start_time_negative_clamped(self):
"""负开始时间钳制到0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"start_time": -3.0,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"start_time": -3.0,
}
)
assert track.start_time == 0.0
def test_disabled_track(self):
"""禁用轨道."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"enabled": False,
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"enabled": False,
}
)
assert track.enabled is False
def test_invalid_fade_in_falls_back(self):
"""无效淡入值回退到0."""
track = AudioTrack.from_dict({
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": "fast",
})
track = AudioTrack.from_dict(
{
"track_id": "t1",
"audio_path": "/a.mp3",
"fade_in": "fast",
}
)
assert track.fade_in == 0.0
@@ -224,89 +251,107 @@ class TestMultiTrackMixConfigFromConfigDict:
def test_single_track(self):
"""单轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{
"track_id": "bgm1",
"track_type": "bgm",
"audio_path": "/bgm.mp3",
},
],
})
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{
"track_id": "bgm1",
"track_type": "bgm",
"audio_path": "/bgm.mp3",
},
],
}
)
assert len(config.tracks) == 1
assert config.tracks[0].track_id == "bgm1"
def test_multiple_tracks(self):
"""多轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "track_type": "bgm", "audio_path": "/a.mp3"},
{"track_id": "t2", "track_type": "sfx", "audio_path": "/b.mp3"},
],
})
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "track_type": "bgm", "audio_path": "/a.mp3"},
{"track_id": "t2", "track_type": "sfx", "audio_path": "/b.mp3"},
],
}
)
assert len(config.tracks) == 2
def test_skips_disabled_tracks(self):
"""跳过禁用轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
],
})
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3", "enabled": True},
{"track_id": "t2", "audio_path": "/b.mp3", "enabled": False},
],
}
)
assert len(config.tracks) == 1
assert config.tracks[0].track_id == "t1"
def test_skips_no_audio_path(self):
"""跳过无audio_path的轨道."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
{"track_id": "t2", "audio_path": ""},
{"track_id": "t3"},
],
})
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
{"track_id": "t2", "audio_path": ""},
{"track_id": "t3"},
],
}
)
assert len(config.tracks) == 1
def test_master_volume(self):
"""主音量."""
config = MultiTrackMixConfig.from_config_dict({
"master_volume": 0.8,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
})
config = MultiTrackMixConfig.from_config_dict(
{
"master_volume": 0.8,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
}
)
assert config.master_volume == 0.8
def test_master_volume_clamped(self):
"""主音量边界钳制."""
config = MultiTrackMixConfig.from_config_dict({
"master_volume": 5.0,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
})
config = MultiTrackMixConfig.from_config_dict(
{
"master_volume": 5.0,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
}
)
assert config.master_volume == 2.0
def test_normalize_disabled(self):
"""禁用归一化."""
config = MultiTrackMixConfig.from_config_dict({
"normalize": False,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
})
config = MultiTrackMixConfig.from_config_dict(
{
"normalize": False,
"tracks": [{"track_id": "t1", "audio_path": "/a.mp3"}],
}
)
assert config.normalize is False
def test_tracks_not_list_ignored(self):
"""tracks不是列表时忽略."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": "not_a_list",
})
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": "not_a_list",
}
)
assert config.tracks == []
def test_non_dict_track_skipped(self):
"""非dict轨道跳过."""
config = MultiTrackMixConfig.from_config_dict({
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
"not_a_dict",
],
})
config = MultiTrackMixConfig.from_config_dict(
{
"tracks": [
{"track_id": "t1", "audio_path": "/a.mp3"},
"not_a_dict",
],
}
)
assert len(config.tracks) == 1
@@ -320,14 +365,18 @@ class TestHasEffect:
def test_with_tracks_has_effect(self):
"""有轨道有效果."""
config = MultiTrackMixConfig(tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3"),
])
config = MultiTrackMixConfig(
tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3"),
]
)
assert config.has_effect is True
def test_disabled_tracks_no_effect(self):
"""所有轨道都禁用无效果."""
config = MultiTrackMixConfig(tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3", enabled=False),
])
config = MultiTrackMixConfig(
tracks=[
AudioTrack(track_id="t1", track_type="bgm", audio_path="/a.mp3", enabled=False),
]
)
assert config.has_effect is False
+48 -35
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.noise_reduction_engine import (
NoiseReductionConfig,
NoiseReductionLevel,
@@ -96,64 +95,78 @@ class TestNoiseReductionConfigFromDict:
def test_noise_floor_parsed(self):
"""噪音阈值解析."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"level": "custom",
"noise_floor": -30.0,
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"level": "custom",
"noise_floor": -30.0,
}
)
assert config.noise_floor == -30.0
def test_noise_floor_clamped_min(self):
"""噪音阈值下限钳制 (-60)."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"level": "custom",
"noise_floor": -100.0,
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"level": "custom",
"noise_floor": -100.0,
}
)
assert config.noise_floor == -60.0
def test_noise_floor_clamped_max(self):
"""噪音阈值上限钳制 (-5)."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"level": "custom",
"noise_floor": 0.0,
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"level": "custom",
"noise_floor": 0.0,
}
)
assert config.noise_floor == -5.0
def test_noise_floor_boundary_low(self):
"""噪音阈值边界值 -60."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"level": "custom",
"noise_floor": -60.0,
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"level": "custom",
"noise_floor": -60.0,
}
)
assert config.noise_floor == -60.0
def test_noise_floor_boundary_high(self):
"""噪音阈值边界值 -5."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"level": "custom",
"noise_floor": -5.0,
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"level": "custom",
"noise_floor": -5.0,
}
)
assert config.noise_floor == -5.0
def test_invalid_noise_floor_falls_back(self):
"""无效噪音阈值 fallback 到默认值."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"level": "custom",
"noise_floor": "not_a_number",
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"level": "custom",
"noise_floor": "not_a_number",
}
)
assert config.noise_floor == -25.0
def test_voice_enhance_enabled(self):
"""人声增强启用."""
config = NoiseReductionConfig.from_dict({
"enabled": True,
"voice_enhance": True,
})
config = NoiseReductionConfig.from_dict(
{
"enabled": True,
"voice_enhance": True,
}
)
assert config.voice_enhance is True
def test_voice_enhance_disabled_default(self):
+3 -10
View File
@@ -8,7 +8,6 @@ from pathlib import Path
from unittest.mock import patch
import pytest
from video_processing.oss_helpers import normalize_storage_key, resolve_asset_path
@@ -21,23 +20,17 @@ class TestNormalizeStorageKey:
def test_https_url_extracts_path(self):
"""HTTPS URL提取path部分."""
result = normalize_storage_key(
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
)
result = normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4")
assert result == "path/to/file.mp4"
def test_http_url_extracts_path(self):
"""HTTP URL提取path部分."""
result = normalize_storage_key(
"http://example.com/assets/video.mp4"
)
result = normalize_storage_key("http://example.com/assets/video.mp4")
assert result == "assets/video.mp4"
def test_url_with_query_params(self):
"""带query参数的URL只取path."""
result = normalize_storage_key(
"https://bucket.oss-cn-hangzhou.aliyuncs.com/file.mp4?token=abc&expires=123"
)
result = normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/file.mp4?token=abc&expires=123")
assert result == "file.mp4"
def test_leading_slash_stripped(self):
+56 -47
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.pip_engine import PiPConfig, PiPLayerConfig
@@ -178,12 +177,14 @@ class TestPiPConfigFromDict:
def test_single_layer(self):
"""单个图层."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": "asset_001", "position": "top_left"},
],
})
config = PiPConfig.from_dict(
{
"enabled": True,
"layers": [
{"source": "asset_001", "position": "top_left"},
],
}
)
assert config.enabled is True
assert len(config.layers) == 1
assert config.layers[0].source == "asset_001"
@@ -191,14 +192,16 @@ class TestPiPConfigFromDict:
def test_multiple_layers_sorted_by_z_index(self):
"""多个图层按z_index排序."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": "a", "z_index": 3},
{"source": "b", "z_index": 1},
{"source": "c", "z_index": 2},
],
})
config = PiPConfig.from_dict(
{
"enabled": True,
"layers": [
{"source": "a", "z_index": 3},
{"source": "b", "z_index": 1},
{"source": "c", "z_index": 2},
],
}
)
assert len(config.layers) == 3
assert config.layers[0].z_index == 1
assert config.layers[1].z_index == 2
@@ -206,48 +209,54 @@ class TestPiPConfigFromDict:
def test_invalid_layer_skipped(self):
"""无效图层跳过."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": "valid_asset"},
{"source": ""}, # 无效,空source
],
})
config = PiPConfig.from_dict(
{
"enabled": True,
"layers": [
{"source": "valid_asset"},
{"source": ""}, # 无效,空source
],
}
)
assert len(config.layers) == 1
assert config.layers[0].source == "valid_asset"
def test_all_invalid_layers_disabled(self):
"""全部无效则disabled."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{"source": ""},
{"source": ""},
],
})
config = PiPConfig.from_dict(
{
"enabled": True,
"layers": [
{"source": ""},
{"source": ""},
],
}
)
assert config.enabled is False
assert config.layers == []
def test_layer_full_config(self):
"""完整图层配置."""
config = PiPConfig.from_dict({
"enabled": True,
"layers": [
{
"source": "https://example.com/video.mp4",
"source_type": "url",
"position": "bottom_right",
"width": "30%",
"opacity": 0.8,
"corner_radius": 10,
"border_width": 2,
"border_color": "red",
"start_time": 5.0,
"duration": 10.0,
"z_index": 5,
},
],
})
config = PiPConfig.from_dict(
{
"enabled": True,
"layers": [
{
"source": "https://example.com/video.mp4",
"source_type": "url",
"position": "bottom_right",
"width": "30%",
"opacity": 0.8,
"corner_radius": 10,
"border_width": 2,
"border_color": "red",
"start_time": 5.0,
"duration": 10.0,
"z_index": 5,
},
],
}
)
assert len(config.layers) == 1
layer = config.layers[0]
assert layer.source == "https://example.com/video.mp4"
-1
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.render_adapter import (
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
-1
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from unittest.mock import patch
import pytest
from video_processing.render_audio import (
RenderContext,
clip_effective_duration,
-2
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.speed_engine import (
MAX_SPEED,
MIN_SPEED,
@@ -11,7 +10,6 @@ from video_processing.speed_engine import (
SpeedEngine,
)
# ── 常量测试 ──────────────────────────────────────────────────
+14 -11
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.sticker_engine import (
ImageStickerConfig,
TextStickerConfig,
@@ -95,21 +94,25 @@ class TestParseStickersFromConfig:
def test_single_sticker(self):
"""单个贴纸."""
result = parse_stickers_from_config({
"stickers": [{"type": "text", "text": "hello"}],
})
result = parse_stickers_from_config(
{
"stickers": [{"type": "text", "text": "hello"}],
}
)
assert len(result) == 1
assert result[0]["text"] == "hello"
def test_multiple_stickers(self):
"""多个贴纸."""
result = parse_stickers_from_config({
"stickers": [
{"type": "text", "text": "a"},
{"type": "image", "image_url": "/b.png"},
{"type": "text", "text": "c"},
],
})
result = parse_stickers_from_config(
{
"stickers": [
{"type": "text", "text": "a"},
{"type": "image", "image_url": "/b.png"},
{"type": "text", "text": "c"},
],
}
)
assert len(result) == 3
def test_returns_raw_dicts(self):
+6 -6
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.subtitle_render_engine import (
SubtitleStyle,
_escape_ass_text,
@@ -14,7 +13,6 @@ from video_processing.subtitle_render_engine import (
_wrap_text,
)
# ── 颜色转换测试 ──────────────────────────────────────────────
@@ -269,10 +267,12 @@ class TestSubtitleStyleFromDict:
def test_background_opacity_clamped(self):
"""背景透明度钳制."""
style = SubtitleStyle.from_dict({
"background_enabled": True,
"background_opacity": 2.0,
})
style = SubtitleStyle.from_dict(
{
"background_enabled": True,
"background_opacity": 2.0,
}
)
assert style.background_opacity == 1.0
def test_invalid_position_falls_back(self):
@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
import pytest
from app.api.routes.templates_editor._utils import (
_clip_type_to_scene_label,
_clip_value,
-1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.thumbnail_generator import _format_seek_time
-1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.transition_engine import (
CUT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
+38 -24
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import pytest
from video_processing.trim_engine import MIN_TRIM_DURATION, TrimConfig, TrimSegment
@@ -20,11 +19,16 @@ class TestTrimConfigFromDict:
def test_all_zero_returns_none(self):
"""全零返回None."""
assert TrimConfig.from_dict({
"start_time": 0,
"end_time": 0,
"duration": 0,
}) is None
assert (
TrimConfig.from_dict(
{
"start_time": 0,
"end_time": 0,
"duration": 0,
}
)
is None
)
def test_start_only(self):
"""只有start_time有效."""
@@ -64,10 +68,12 @@ class TestTrimConfigFromDict:
def test_string_values_converted(self):
"""字符串值会被转换."""
config = TrimConfig.from_dict({
"start_time": "5.0",
"duration": "10.0",
})
config = TrimConfig.from_dict(
{
"start_time": "5.0",
"duration": "10.0",
}
)
assert config is not None
assert config.start_time == 5.0
assert config.duration == 10.0
@@ -235,11 +241,14 @@ class TestTrimSegment:
def test_from_dict_basic(self):
"""基本解析."""
seg = TrimSegment.from_dict({
"start_time": 5.0,
"duration": 10.0,
"segment_id": "seg1",
}, default_order=0)
seg = TrimSegment.from_dict(
{
"start_time": 5.0,
"duration": 10.0,
"segment_id": "seg1",
},
default_order=0,
)
assert seg.segment_id == "seg1"
assert seg.trim.start_time == 5.0
assert seg.trim.duration == 10.0
@@ -247,11 +256,13 @@ class TestTrimSegment:
def test_from_dict_with_order(self):
"""带order的解析."""
seg = TrimSegment.from_dict({
"start_time": 1.0,
"end_time": 4.0,
"order": 2,
})
seg = TrimSegment.from_dict(
{
"start_time": 1.0,
"end_time": 4.0,
"order": 2,
}
)
assert seg.order == 2
assert seg.trim.start_time == 1.0
assert seg.trim.end_time == 4.0
@@ -264,8 +275,11 @@ class TestTrimSegment:
def test_from_dict_empty_string_segment_id(self):
"""空字符串segment_id走默认."""
seg = TrimSegment.from_dict({
"segment_id": "",
"duration": 5.0,
}, default_order=5)
seg = TrimSegment.from_dict(
{
"segment_id": "",
"duration": 5.0,
},
default_order=5,
)
assert seg.segment_id == "seg_5"
+6 -5
View File
@@ -5,12 +5,11 @@ from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.unified_render_service import (
RenderLayer,
ResolvedClip,
_LAYER_Z_INDEX,
_PIP_SCALE,
RenderLayer,
ResolvedClip,
_resolve_layer_role,
)
@@ -159,9 +158,11 @@ class TestRenderLayer:
def test_with_clips(self):
"""带片段的图层."""
clip = ResolvedClip(
clip_id="c1", asset_id="a1",
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/t.mp4"),
clip_type="main", order=0,
clip_type="main",
order=0,
)
layer = RenderLayer(role="overlay", clips=[clip], z_index=1)
assert len(layer.clips) == 1
+118 -114
View File
@@ -3,14 +3,12 @@
from __future__ import annotations
import pytest
from video_processing.watermark_engine import (
WATERMARK_POSITIONS,
WatermarkConfig,
WatermarkEngine,
)
# ── WatermarkConfig 测试 ──────────────────────────────────────────
@@ -52,11 +50,13 @@ class TestWatermarkConfigFromDict:
def test_text_mode_basic(self):
"""文字水印基本配置."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "测试水印",
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "测试水印",
}
)
assert config is not None
assert config.mode == "text"
assert config.text == "测试水印"
@@ -64,86 +64,102 @@ class TestWatermarkConfigFromDict:
def test_text_mode_missing_text_returns_none(self):
"""文字水印缺少 text 返回 None."""
result = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
})
result = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
}
)
assert result is None
def test_text_mode_empty_text_returns_none(self):
"""文字水印 text 为空返回 None."""
result = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "",
})
result = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "",
}
)
assert result is None
def test_image_mode_basic(self):
"""图片水印基本配置."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "image",
"image_path": "/path/to/logo.png",
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "image",
"image_path": "/path/to/logo.png",
}
)
assert config is not None
assert config.mode == "image"
assert config.image_path == "/path/to/logo.png"
def test_image_mode_missing_image_returns_none(self):
"""图片水印缺少 image_path 返回 None."""
result = WatermarkConfig.from_dict({
"enabled": True,
"mode": "image",
})
result = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "image",
}
)
assert result is None
def test_image_mode_image_alias(self):
"""image 字段作为 image_path 的别名."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "image",
"image": "/path/alias.png",
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "image",
"image": "/path/alias.png",
}
)
assert config is not None
assert config.image_path == "/path/alias.png"
def test_invalid_position_falls_back(self):
"""无效位置 fallback 到 bottom_right."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "test",
"position": "invalid_pos",
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "test",
"position": "invalid_pos",
}
)
assert config is not None
assert config.position == "bottom_right"
def test_custom_position_valid(self):
"""自定义有效位置."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "test",
"position": "top_left",
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "test",
"position": "top_left",
}
)
assert config is not None
assert config.position == "top_left"
def test_all_text_fields_parsed(self):
"""文字水印所有字段正确解析."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "我的水印",
"font_size": 32,
"font_color": "red",
"font_path": "/fonts/msyh.ttf",
"position": "top_center",
"opacity": 0.5,
"margin_x": 30,
"margin_y": 40,
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "我的水印",
"font_size": 32,
"font_color": "red",
"font_path": "/fonts/msyh.ttf",
"position": "top_center",
"opacity": 0.5,
"margin_x": 30,
"margin_y": 40,
}
)
assert config is not None
assert config.text == "我的水印"
assert config.font_size == 32
@@ -156,16 +172,18 @@ class TestWatermarkConfigFromDict:
def test_all_image_fields_parsed(self):
"""图片水印所有字段正确解析."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "image",
"image_path": "/img/logo.png",
"scale": 0.3,
"opacity": 0.9,
"position": "bottom_left",
"margin_x": 10,
"margin_y": 15,
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "image",
"image_path": "/img/logo.png",
"scale": 0.3,
"opacity": 0.9,
"position": "bottom_left",
"margin_x": 10,
"margin_y": 15,
}
)
assert config is not None
assert config.image_path == "/img/logo.png"
assert config.scale == 0.3
@@ -174,23 +192,27 @@ class TestWatermarkConfigFromDict:
def test_scroll_config_parsed(self):
"""滚动水印配置解析."""
config = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "滚动水印",
"scroll": True,
"scroll_speed": 80,
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "滚动水印",
"scroll": True,
"scroll_speed": 80,
}
)
assert config is not None
assert config.scroll is True
assert config.scroll_speed == 80
def test_default_mode_is_text(self):
"""不传 mode 默认为 text."""
config = WatermarkConfig.from_dict({
"enabled": True,
"text": "默认模式",
})
config = WatermarkConfig.from_dict(
{
"enabled": True,
"text": "默认模式",
}
)
assert config is not None
assert config.mode == "text"
@@ -325,95 +347,71 @@ class TestCalcPosition:
def test_top_left(self):
"""左上角."""
x, y = WatermarkEngine.calc_position(
"top_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("top_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert (x, y) == (20, 20)
def test_top_center(self):
"""中上."""
x, y = WatermarkEngine.calc_position(
"top_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("top_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == (1920 - 200) // 2
assert y == 20
def test_top_right(self):
"""右上角."""
x, y = WatermarkEngine.calc_position(
"top_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("top_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == 1920 - 200 - 20
assert y == 20
def test_center_left(self):
"""左中."""
x, y = WatermarkEngine.calc_position(
"center_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("center_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == 20
assert y == (1080 - 100) // 2
def test_center(self):
"""中心."""
x, y = WatermarkEngine.calc_position(
"center", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("center", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == (1920 - 200) // 2
assert y == (1080 - 100) // 2
def test_center_right(self):
"""右中."""
x, y = WatermarkEngine.calc_position(
"center_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("center_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == 1920 - 200 - 20
assert y == (1080 - 100) // 2
def test_bottom_left(self):
"""左下角."""
x, y = WatermarkEngine.calc_position(
"bottom_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("bottom_left", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == 20
assert y == 1080 - 100 - 20
def test_bottom_center(self):
"""中下."""
x, y = WatermarkEngine.calc_position(
"bottom_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("bottom_center", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == (1920 - 200) // 2
assert y == 1080 - 100 - 20
def test_bottom_right(self):
"""右下角."""
x, y = WatermarkEngine.calc_position(
"bottom_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("bottom_right", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == 1920 - 200 - 20
assert y == 1080 - 100 - 20
def test_unknown_position_defaults_bottom_right(self):
"""未知位置默认右下角."""
x, y = WatermarkEngine.calc_position(
"unknown", self.W, self.H, self.WW, self.WH, self.MX, self.MY
)
x, y = WatermarkEngine.calc_position("unknown", self.W, self.H, self.WW, self.WH, self.MX, self.MY)
assert x == 1920 - 200 - 20
assert y == 1080 - 100 - 20
def test_zero_margin(self):
"""零边距."""
x, y = WatermarkEngine.calc_position(
"top_left", 1000, 500, 100, 50, 0, 0
)
x, y = WatermarkEngine.calc_position("top_left", 1000, 500, 100, 50, 0, 0)
assert (x, y) == (0, 0)
def test_small_output(self):
"""小尺寸输出."""
x, y = WatermarkEngine.calc_position(
"bottom_right", 320, 240, 50, 30, 5, 5
)
x, y = WatermarkEngine.calc_position("bottom_right", 320, 240, 50, 30, 5, 5)
assert x == 320 - 50 - 5
assert y == 240 - 30 - 5
@@ -446,8 +444,14 @@ class TestWatermarkPositions:
def test_all_position_keys_valid(self):
"""所有位置键名正确."""
expected = {
"top_left", "top_center", "top_right",
"center_left", "center", "center_right",
"bottom_left", "bottom_center", "bottom_right",
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
}
assert set(WATERMARK_POSITIONS.keys()) == expected