test(unit): 第67波 - trim引擎 + concat引擎 + subtitle_render引擎配置解析 (+112) #863

Merged
xiaoxia merged 1 commits from test/wave67-trim-concat-subtitle-render into develop 2026-07-25 08:13:12 +08:00
3 changed files with 779 additions and 459 deletions
+225 -226
View File
@@ -1,271 +1,270 @@
"""
视频拼接引擎配置与纯逻辑测试.
覆盖 ConcatSegment.from_dict / ConcatConfig.from_config_dict / has_effect / total_segments 等纯逻辑.
引擎核心 render 方法依赖 FFmpeg,由集成测试覆盖.
"""
"""拼接引擎单元测试 - 配置解析等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.concat_engine import ConcatConfig, ConcatSegment
class TestConcatSegmentFromDict:
"""ConcatSegment.from_dict 构造逻辑."""
class TestConcatSegmentDefaults:
"""ConcatSegment 默认值测试."""
def test_basic(self):
seg = ConcatSegment.from_dict({"video_path": "/tmp/a.mp4"})
assert seg.video_path == "/tmp/a.mp4"
def test_default_values(self):
"""默认值正确."""
seg = ConcatSegment(video_path="/a.mp4")
assert seg.video_path == "/a.mp4"
assert seg.start_time == 0.0
assert seg.duration == 0.0
assert seg.has_audio is True
def test_full_fields(self):
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/b.mp4",
"start_time": 5.5,
"duration": 10.0,
"has_audio": False,
}
)
assert seg.video_path == "/tmp/b.mp4"
assert seg.start_time == 5.5
class TestConcatSegmentFromDict:
"""ConcatSegment.from_dict 测试."""
def test_basic_path(self):
"""基本路径."""
seg = ConcatSegment.from_dict({"video_path": "/a.mp4"})
assert seg.video_path == "/a.mp4"
assert seg.start_time == 0.0
assert seg.duration == 0.0
def test_custom_start_time(self):
"""自定义开始时间."""
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,
})
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,
})
assert seg.start_time == 0.0
def test_duration_negative_clamped(self):
"""负时长钳制到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",
})
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",
})
assert seg.duration == 0.0
def test_no_audio(self):
"""无音频."""
seg = ConcatSegment.from_dict({
"video_path": "/a.mp4",
"has_audio": False,
})
assert seg.has_audio is False
def test_negative_start_time_clamped(self):
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/a.mp4",
"start_time": -1.0,
}
)
assert seg.start_time == 0.0
def test_full_config(self):
"""完整配置."""
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
assert seg.has_audio is False
def test_negative_duration_clamped(self):
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/a.mp4",
"duration": -5.0,
}
)
assert seg.duration == 0.0
def test_invalid_start_time_type_falls_back(self):
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/a.mp4",
"start_time": "not_a_number",
}
)
assert seg.start_time == 0.0
class TestConcatConfigDefaults:
"""ConcatConfig 默认值测试."""
def test_invalid_duration_type_falls_back(self):
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/a.mp4",
"duration": "abc",
}
)
assert seg.duration == 0.0
def test_start_time_none_falls_back(self):
seg = ConcatSegment.from_dict(
{
"video_path": "/tmp/a.mp4",
"start_time": None,
}
)
assert seg.start_time == 0.0
def test_empty_video_path_stored(self):
seg = ConcatSegment.from_dict({"video_path": ""})
assert seg.video_path == ""
def test_default_values(self):
"""默认值正确."""
config = ConcatConfig()
assert config.segments == []
assert config.output_width == 0
assert config.output_height == 0
assert config.output_fps == 0.0
assert config.force_reencode is False
assert config.transition == "none"
assert config.transition_duration == 0.3
class TestConcatConfigFromConfigDict:
"""ConcatConfig.from_config_dict 构造逻辑."""
"""ConcatConfig.from_config_dict 测试."""
def test_none_returns_default(self):
cfg = ConcatConfig.from_config_dict(None)
assert cfg.segments == []
assert cfg.output_width == 0
assert cfg.output_height == 0
assert cfg.output_fps == 0.0
assert cfg.force_reencode is False
"""None返回默认配置."""
config = ConcatConfig.from_config_dict(None)
assert config.segments == []
def test_empty_dict_returns_default(self):
cfg = ConcatConfig.from_config_dict({})
assert cfg.segments == []
def test_non_dict_returns_default(self):
cfg = ConcatConfig.from_config_dict("not a dict")
assert cfg.segments == []
"""空dict返回默认."""
config = ConcatConfig.from_config_dict({})
assert config.segments == []
def test_single_segment(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/tmp/a.mp4", "duration": 5.0},
],
}
)
assert len(cfg.segments) == 1
assert cfg.segments[0].video_path == "/tmp/a.mp4"
assert cfg.segments[0].duration == 5.0
"""单片段."""
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):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/tmp/a.mp4"},
{"video_path": "/tmp/b.mp4", "start_time": 2.0},
{"video_path": "/tmp/c.mp4", "duration": 3.0, "has_audio": False},
],
}
)
assert len(cfg.segments) == 3
assert cfg.segments[0].video_path == "/tmp/a.mp4"
assert cfg.segments[1].start_time == 2.0
assert cfg.segments[2].has_audio is False
"""多片段."""
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_invalid_segments_filtered(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "/tmp/valid.mp4"},
{"video_path": ""}, # 空路径被过滤
{"not_video_path": "xxx"}, # 没有video_path被过滤
"not_a_dict", # 不是dict被过滤
None, # None被过滤
],
}
)
assert len(cfg.segments) == 1
assert cfg.segments[0].video_path == "/tmp/valid.mp4"
def test_skips_no_path(self):
"""跳过无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_a_list(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": "not_a_list",
}
)
assert cfg.segments == []
def test_segments_not_list_ignored(self):
"""segments不是列表忽略."""
config = ConcatConfig.from_config_dict({
"segments": "not_a_list",
})
assert config.segments == []
def test_output_params(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [],
"output_width": 1920,
"output_height": 1080,
"output_fps": 30.0,
"force_reencode": True,
}
)
assert cfg.output_width == 1920
assert cfg.output_height == 1080
assert cfg.output_fps == 30.0
assert cfg.force_reencode is True
def test_output_size(self):
"""输出尺寸."""
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_params_clamped(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [],
"output_width": -100,
"output_height": -50,
"output_fps": -1.0,
}
)
assert cfg.output_width == 0
assert cfg.output_height == 0
assert cfg.output_fps == 0.0
def test_negative_output_size_clamped(self):
"""负输出尺寸钳制到0."""
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_params_fall_back(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [],
"output_width": "abc",
"output_height": None,
"output_fps": "xyz",
}
)
assert cfg.output_width == 0
assert cfg.output_height == 0
assert cfg.output_fps == 0.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",
})
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,
})
assert config.output_fps == 60.0
def test_force_reencode(self):
"""强制重新编码."""
config = ConcatConfig.from_config_dict({
"segments": [{"video_path": "/a.mp4"}],
"force_reencode": True,
})
assert config.force_reencode is True
def test_transition_config(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [],
"transition": "crossfade",
"transition_duration": 1.0,
}
)
assert cfg.transition == "crossfade"
assert cfg.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
def test_transition_duration_minimum(self):
"""transition_duration 不能小于 0.1."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [],
"transition_duration": 0.01,
}
)
assert cfg.transition_duration >= 0.1
def test_default_values(self):
cfg = ConcatConfig.from_config_dict({"segments": []})
assert cfg.transition == "none"
assert cfg.transition_duration == 0.3
assert cfg.force_reencode is False
def test_non_dict_config_returns_default(self):
"""非dict配置返回默认."""
config = ConcatConfig.from_config_dict("not_a_dict")
assert config.segments == []
class TestConcatConfigProperties:
"""has_effect / total_segments 属性."""
class TestHasEffect:
"""has_effect 属性测试."""
def test_has_effect_two_or_more_valid(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="/tmp/a.mp4"),
ConcatSegment(video_path="/tmp/b.mp4"),
]
)
assert cfg.has_effect is True
def test_no_segments_no_effect(self):
"""无片段无效果."""
config = ConcatConfig()
assert config.has_effect is False
def test_no_effect_one_segment(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="/tmp/a.mp4"),
]
)
assert cfg.has_effect is False
def test_one_segment_no_effect(self):
"""单片段无效果(拼接至少需要2段)."""
config = ConcatConfig(segments=[
ConcatSegment(video_path="/a.mp4"),
])
assert config.has_effect is False
def test_no_effect_zero_segments(self):
cfg = ConcatConfig(segments=[])
assert cfg.has_effect is False
def test_two_segments_has_effect(self):
"""两段及以上有效果."""
config = ConcatConfig(segments=[
ConcatSegment(video_path="/a.mp4"),
ConcatSegment(video_path="/b.mp4"),
])
assert config.has_effect is True
def test_no_effect_empty_paths(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""),
ConcatSegment(video_path=""),
]
)
assert cfg.has_effect is False
def test_total_segments(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="/tmp/a.mp4"),
ConcatSegment(video_path=""),
ConcatSegment(video_path="/tmp/b.mp4"),
]
)
assert cfg.total_segments == 2
class TestTotalSegments:
"""total_segments 属性测试."""
def test_total_segments_empty(self):
cfg = ConcatConfig(segments=[])
assert cfg.total_segments == 0
def test_no_segments(self):
"""零片段."""
config = ConcatConfig()
assert config.total_segments == 0
def test_three_segments(self):
"""三个片段."""
config = ConcatConfig(segments=[
ConcatSegment(video_path="/a.mp4"),
ConcatSegment(video_path="/b.mp4"),
ConcatSegment(video_path="/c.mp4"),
])
assert config.total_segments == 3
+318
View File
@@ -0,0 +1,318 @@
"""字幕渲染引擎单元测试 - 工具函数+样式配置等纯逻辑."""
from __future__ import annotations
import pytest
from video_processing.subtitle_render_engine import (
SubtitleStyle,
_escape_ass_text,
_format_ass_time,
_hex_to_ass_bgr,
_hex_to_ass_color,
_opacity_to_ass_alpha,
_wrap_text,
)
# ── 颜色转换测试 ──────────────────────────────────────────────
class TestHexToAssColor:
"""_hex_to_ass_color 测试."""
def test_white(self):
"""白色."""
assert _hex_to_ass_color("#FFFFFF") == "&H00FFFFFF"
def test_black(self):
"""黑色."""
assert _hex_to_ass_color("#000000") == "&H00000000"
def test_red(self):
"""红色 → BGR: 蓝绿红."""
assert _hex_to_ass_color("#FF0000") == "&H000000FF"
def test_green(self):
"""绿色."""
assert _hex_to_ass_color("#00FF00") == "&H0000FF00"
def test_blue(self):
"""蓝色."""
assert _hex_to_ass_color("#0000FF") == "&H00FF0000"
def test_no_hash_prefix(self):
"""不带#号."""
assert _hex_to_ass_color("FF0000") == "&H000000FF"
def test_invalid_length(self):
"""长度不对返回默认白色."""
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF"
assert _hex_to_ass_color("#FF") == "&H00FFFFFF"
assert _hex_to_ass_color("") == "&H00FFFFFF"
def test_lowercase_input(self):
"""小写输入转为大写输出."""
assert _hex_to_ass_color("#aabbcc") == "&H00CCBBAA"
class TestHexToAssBgr:
"""_hex_to_ass_bgr 测试."""
def test_white(self):
"""白色BGR."""
assert _hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
def test_red_bgr(self):
"""红色 → BGR = 0000FF."""
assert _hex_to_ass_bgr("#FF0000") == "0000FF"
def test_blue_bgr(self):
"""蓝色 → BGR = FF0000."""
assert _hex_to_ass_bgr("#0000FF") == "FF0000"
def test_invalid_length(self):
"""长度不对返回默认."""
assert _hex_to_ass_bgr("#FF") == "FFFFFF"
class TestOpacityToAssAlpha:
"""_opacity_to_ass_alpha 测试."""
def test_fully_opaque(self):
"""完全不透明 → 00."""
assert _opacity_to_ass_alpha(1.0) == "00"
def test_fully_transparent(self):
"""完全透明 → FF."""
assert _opacity_to_ass_alpha(0.0) == "FF"
def test_half(self):
"""50% → 128 → 80."""
assert _opacity_to_ass_alpha(0.5) == "80"
def test_quarter(self):
"""75%不透明 → 64 → 40."""
assert _opacity_to_ass_alpha(0.75) == "40"
# ── 文本处理测试 ──────────────────────────────────────────────
class TestEscapeAssText:
"""_escape_ass_text 转义测试."""
def test_normal_text_unchanged(self):
"""普通文本不变."""
assert _escape_ass_text("hello world") == "hello world"
def test_newline_converted(self):
"""换行转成\\N."""
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_crlf_converted(self):
"""\\r\\n转成\\N."""
assert _escape_ass_text("line1\r\nline2") == "line1\\Nline2"
def test_carriage_return_converted(self):
"""\\r转成\\N."""
assert _escape_ass_text("line1\rline2") == "line1\\Nline2"
def test_curly_braces_replaced(self):
"""花括号替换成圆括号(ASS控制符)."""
assert _escape_ass_text("{text}") == "(text)"
def test_mixed_special_chars(self):
"""混合特殊字符."""
text = "hello\n{world}\r\nend"
result = _escape_ass_text(text)
assert "\\N" in result
assert "{" not in result
assert "}" not in result
assert "(world)" in result
class TestFormatAssTime:
"""_format_ass_time 时间格式化测试."""
def test_zero(self):
"""0秒."""
assert _format_ass_time(0) == "0:00:00.00"
def test_seconds_only(self):
"""只有秒."""
assert _format_ass_time(5.5) == "0:00:05.50"
def test_minutes_and_seconds(self):
"""分+秒."""
assert _format_ass_time(125.5) == "0:02:05.50"
def test_hours_minutes_seconds(self):
"""时+分+秒."""
assert _format_ass_time(3725.25) == "1:02:05.25"
def test_exactly_one_hour(self):
"""刚好1小时."""
assert _format_ass_time(3600.0) == "1:00:00.00"
def test_single_digit_minute(self):
"""分钟补零."""
result = _format_ass_time(65.0)
parts = result.split(":")
assert parts[1] == "01"
def test_always_two_decimal_places(self):
"""总是两位小数."""
result = _format_ass_time(3.0)
assert result.endswith(".00")
class TestWrapText:
"""_wrap_text 换行测试."""
def test_short_text_no_wrap(self):
"""短文本不换行."""
result = _wrap_text("hello", 10)
assert len(result) == 1
assert result[0] == "hello"
def test_exact_length_no_wrap(self):
"""刚好长度不换行."""
text = "abcdefghij" # 10 chars
result = _wrap_text(text, 10)
assert len(result) == 1
assert result[0] == text
def test_simple_wrap(self):
"""简单换行."""
text = "abcdefghijklmnopqrst" # 20 chars
result = _wrap_text(text, 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_uneven_wrap(self):
"""不均等换行."""
text = "abcdefghijklm" # 13 chars
result = _wrap_text(text, 5)
assert len(result) == 3
assert result[0] == "abcde"
assert result[1] == "fghij"
assert result[2] == "klm"
def test_chinese_text_wrap(self):
"""中文文本换行(按字符数)."""
text = "一二三四五六七八九十"
result = _wrap_text(text, 5)
assert len(result) == 2
assert result[0] == "一二三四五"
assert result[1] == "六七八九十"
# ── SubtitleStyle 测试 ────────────────────────────────────
class TestSubtitleStyleDefaults:
"""SubtitleStyle 默认值测试."""
def test_default_values(self):
"""默认值正确."""
style = SubtitleStyle()
assert style.font_size > 0
assert style.bold is False
assert style.italic is False
assert style.stroke_enabled is True
assert style.shadow_enabled is False
assert style.background_enabled is False
assert style.fade_in == 0.0
assert style.fade_out == 0.0
assert style.animation_type == "none"
class TestSubtitleStyleFromDict:
"""SubtitleStyle.from_dict 测试."""
def test_none_returns_default(self):
"""None返回默认样式."""
style = SubtitleStyle.from_dict(None)
assert isinstance(style, SubtitleStyle)
def test_empty_dict_returns_default(self):
"""空dict返回默认."""
style = SubtitleStyle.from_dict({})
assert isinstance(style, SubtitleStyle)
def test_custom_font_size(self):
"""自定义字号."""
style = SubtitleStyle.from_dict({"size": 48})
assert style.font_size == 48
def test_custom_color(self):
"""自定义颜色."""
style = SubtitleStyle.from_dict({"color": "#FF0000"})
assert style.font_color == "#FF0000"
def test_bold_enabled(self):
"""启用粗体."""
style = SubtitleStyle.from_dict({"bold": True})
assert style.bold is True
def test_stroke_disabled(self):
"""禁用描边."""
style = SubtitleStyle.from_dict({"stroke_enabled": False})
assert style.stroke_enabled is False
def test_background_enabled(self):
"""启用背景框."""
style = SubtitleStyle.from_dict({"background_enabled": True})
assert style.background_enabled is True
def test_background_opacity_clamped(self):
"""背景透明度钳制."""
style = SubtitleStyle.from_dict({
"background_enabled": True,
"background_opacity": 2.0,
})
assert style.background_opacity == 1.0
def test_invalid_position_falls_back(self):
"""无效位置回退到默认."""
style = SubtitleStyle.from_dict({"position": "invalid_pos"})
# 回退到默认位置
assert style.position is not None
def test_fade_in_non_negative(self):
"""淡入时长不能为负."""
style = SubtitleStyle.from_dict({"fade_in": -1.0})
assert style.fade_in == 0.0
def test_custom_animation(self):
"""自定义动画."""
style = SubtitleStyle.from_dict({"animation_type": "fade"})
assert style.animation_type == "fade"
class TestSubtitleStyleProperties:
"""SubtitleStyle 属性测试."""
def test_ass_font_color_format(self):
"""ass_font_color格式正确."""
style = SubtitleStyle(font_color="#FF0000")
result = style.ass_font_color
assert result.startswith("&H")
assert len(result) == 10 # &H + AABBGGRR = 10 chars
def test_ass_background_color_format(self):
"""背景颜色格式正确."""
style = SubtitleStyle(
background_enabled=True,
background_color="#000000",
background_opacity=0.5,
)
result = style.ass_background_color
assert result.startswith("&H")
def test_alignment_is_int(self):
"""alignment是整数."""
style = SubtitleStyle()
assert isinstance(style.alignment, int)
+236 -233
View File
@@ -1,268 +1,271 @@
"""裁剪引擎单元测试."""
"""裁剪引擎单元测试 - 配置解析+推导等纯逻辑."""
import sys
import unittest
from pathlib import Path
from __future__ import annotations
# 确保 apps/worker 在路径中
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
import pytest
from video_processing.trim_engine import (
MIN_TRIM_DURATION,
TrimConfig,
TrimEngine,
TrimSegment,
extract_trim_from_clip_config,
)
from video_processing.trim_engine import MIN_TRIM_DURATION, TrimConfig, TrimSegment
class TestTrimConfig(unittest.TestCase):
"""TrimConfig 单元测试."""
class TestTrimConfigFromDict:
"""TrimConfig.from_dict 解析测试."""
def test_from_dict_none(self):
"""空字典返回 None(不裁剪)."""
self.assertIsNone(TrimConfig.from_dict(None))
self.assertIsNone(TrimConfig.from_dict({}))
def test_none_returns_none(self):
"""None返回None(不裁剪)."""
assert TrimConfig.from_dict(None) is None
def test_from_dict_with_start(self):
"""只有 start_time."""
cfg = TrimConfig.from_dict({"start_time": 5.0})
self.assertIsNotNone(cfg)
self.assertEqual(cfg.start_time, 5.0)
self.assertEqual(cfg.end_time, 0.0)
self.assertEqual(cfg.duration, 0.0)
def test_empty_dict_returns_none(self):
"""空dict返回None."""
assert TrimConfig.from_dict({}) is None
def test_from_dict_with_duration(self):
"""只有 duration."""
cfg = TrimConfig.from_dict({"duration": 10.0})
self.assertIsNotNone(cfg)
self.assertEqual(cfg.start_time, 0.0)
self.assertEqual(cfg.duration, 10.0)
def test_all_zero_returns_none(self):
"""全零返回None."""
assert TrimConfig.from_dict({
"start_time": 0,
"end_time": 0,
"duration": 0,
}) is None
def test_resolve_start_and_end(self):
"""start + end 推导 duration."""
cfg = TrimConfig(start_time=5.0, end_time=15.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 5.0)
self.assertEqual(resolved.end_time, 15.0)
self.assertAlmostEqual(resolved.duration, 10.0, places=3)
self.assertTrue(resolved.is_valid)
def test_start_only(self):
"""只有start_time有效."""
config = TrimConfig.from_dict({"start_time": 5.0})
assert config is not None
assert config.start_time == 5.0
assert config.end_time == 0
assert config.duration == 0
def test_resolve_start_and_duration(self):
"""start + duration 推导 end."""
cfg = TrimConfig(start_time=5.0, duration=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 5.0)
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
self.assertEqual(resolved.duration, 10.0)
def test_duration_only(self):
"""只有duration有效."""
config = TrimConfig.from_dict({"duration": 10.0})
assert config is not None
assert config.start_time == 0
assert config.duration == 10.0
def test_resolve_end_and_duration(self):
"""end + duration 推导 start."""
cfg = TrimConfig(end_time=20.0, duration=8.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertAlmostEqual(resolved.start_time, 12.0, places=3)
self.assertEqual(resolved.end_time, 20.0)
self.assertEqual(resolved.duration, 8.0)
def test_start_and_duration(self):
"""start + duration."""
config = TrimConfig.from_dict({"start_time": 2.0, "duration": 5.0})
assert config is not None
assert config.start_time == 2.0
assert config.duration == 5.0
def test_resolve_only_start(self):
"""只有 start → 取到末尾."""
cfg = TrimConfig(start_time=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 10.0)
self.assertEqual(resolved.end_time, 30.0)
self.assertAlmostEqual(resolved.duration, 20.0, places=3)
def test_start_and_end(self):
"""start + end."""
config = TrimConfig.from_dict({"start_time": 1.0, "end_time": 4.0})
assert config is not None
assert config.start_time == 1.0
assert config.end_time == 4.0
def test_resolve_only_duration(self):
"""只有 duration → 从开头取."""
cfg = TrimConfig(duration=15.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 0.0)
self.assertAlmostEqual(resolved.end_time, 15.0, places=3)
self.assertEqual(resolved.duration, 15.0)
def test_end_and_duration(self):
"""end + duration."""
config = TrimConfig.from_dict({"end_time": 10.0, "duration": 3.0})
assert config is not None
assert config.end_time == 10.0
assert config.duration == 3.0
def test_boundary_clamp_end(self):
"""end 超出素材时长 → 钳制."""
cfg = TrimConfig(start_time=5.0, duration=30.0)
resolved = cfg.validate_and_resolve(asset_duration=20.0)
self.assertEqual(resolved.start_time, 5.0)
self.assertEqual(resolved.end_time, 20.0)
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
def test_string_values_converted(self):
"""字符串值会被转换."""
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
def test_boundary_clamp_start_negative(self):
"""start 为负 → 钳制到 0."""
cfg = TrimConfig(start_time=-5.0, duration=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertEqual(resolved.start_time, 0.0)
self.assertAlmostEqual(resolved.end_time, 10.0, places=3)
self.assertEqual(resolved.duration, 10.0)
def test_falsy_start_with_duration(self):
"""start=0 + duration>0有效."""
config = TrimConfig.from_dict({"start_time": 0, "duration": 5.0})
assert config is not None
assert config.start_time == 0.0
assert config.duration == 5.0
def test_boundary_start_past_end(self):
"""start 超过素材总时长 → 钳制到末尾最小片段."""
cfg = TrimConfig(start_time=50.0, duration=5.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertTrue(resolved.start_time < 30.0)
self.assertEqual(resolved.end_time, 30.0)
self.assertTrue(resolved.duration >= MIN_TRIM_DURATION)
def test_invalid_end_before_start(self):
"""end <= start → 无效."""
cfg = TrimConfig(start_time=15.0, end_time=10.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
self.assertFalse(resolved.is_valid)
class TestValidateAndResolve:
"""validate_and_resolve 推导测试."""
def test_zero_duration_invalid(self):
"""duration 为 0 → 无效."""
cfg = TrimConfig(start_time=5.0, duration=0.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
# 只有 start 没有 duration → 会被推导为取到末尾
self.assertTrue(resolved.is_valid)
self.assertEqual(resolved.end_time, 30.0)
def test_start_plus_end(self):
"""start + end → 推导duration."""
config = TrimConfig(start_time=2.0, end_time=7.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 2.0
assert resolved.end_time == 7.0
assert resolved.duration == 5.0
def test_is_noop(self):
"""is_noop 判断."""
noop = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
self.assertTrue(noop.is_noop)
def test_start_plus_duration(self):
"""start + duration → 推导end."""
config = TrimConfig(start_time=3.0, duration=10.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 3.0
assert resolved.duration == 10.0
assert resolved.end_time == 13.0
not_noop = TrimConfig(start_time=5.0, duration=10.0)
self.assertFalse(not_noop.is_noop)
def test_end_plus_duration(self):
"""end + duration → 推导start."""
config = TrimConfig(end_time=15.0, duration=5.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.end_time == 15.0
assert resolved.duration == 5.0
assert resolved.start_time == 10.0
def test_start_only_takes_to_end(self):
"""只有start → 取到素材末尾."""
config = TrimConfig(start_time=50.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 50.0
assert resolved.end_time == 60.0
assert resolved.duration == 10.0
def test_end_only_takes_from_start(self):
"""只有end → 从开头取."""
config = TrimConfig(end_time=20.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 0.0
assert resolved.end_time == 20.0
assert resolved.duration == 20.0
def test_end_before_start_invalid(self):
"""end < start → 无效(0时长)."""
config = TrimConfig(start_time=10.0, end_time=5.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.duration == 0.0
assert resolved.is_valid is False
def test_negative_start_clamped(self):
"""负start钳制到0."""
config = TrimConfig(start_time=-5.0, duration=10.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 0.0
assert resolved.duration == 10.0
def test_end_beyond_asset_clamped(self):
"""end超过素材时长钳制."""
config = TrimConfig(start_time=50.0, duration=20.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.end_time == 60.0
assert resolved.duration == 10.0
def test_start_beyond_asset_clamped(self):
"""start超过素材时长 → 钳制到末尾保留MIN_TRIM."""
config = TrimConfig(start_time=100.0, duration=5.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 60.0 - MIN_TRIM_DURATION
assert resolved.end_time == 60.0
def test_zero_asset_duration(self):
"""素材时长为 0 → 不裁剪."""
cfg = TrimConfig(start_time=5.0, duration=10.0)
resolved = cfg.validate_and_resolve(asset_duration=0.0)
self.assertTrue(resolved.is_noop)
"""素材时长为0 → 不裁剪."""
config = TrimConfig(start_time=1.0, duration=5.0)
resolved = config.validate_and_resolve(0.0)
assert resolved.start_time == 0.0
assert resolved.duration == 0.0
def test_all_three_params_use_start_duration(self):
"""三个参数都给了 → 以 start + duration 为准."""
cfg = TrimConfig(start_time=5.0, end_time=20.0, duration=8.0)
resolved = cfg.validate_and_resolve(asset_duration=30.0)
# validate_and_resolve 中 start+end 优先于 start+duration
# 因为先检查的是 start>0 and end>0
self.assertAlmostEqual(resolved.duration, 15.0, places=3)
def test_end_and_duration_with_negative_start(self):
"""end + duration推导出来负start → 钳制+重算."""
config = TrimConfig(end_time=3.0, duration=10.0)
resolved = config.validate_and_resolve(60.0)
assert resolved.start_time == 0.0
assert resolved.end_time == 3.0
assert resolved.duration == 3.0
def test_all_three_params_uses_start_duration(self):
"""三个都给了,以start+duration为准."""
# 实际代码是先判断 start+end(情况1),如果都>0就用
# 所以这里测试 start+end 都给了且都>0的情况
config = TrimConfig(start_time=2.0, end_time=8.0, duration=10.0)
resolved = config.validate_and_resolve(60.0)
# 走情况1start+end都有)
assert resolved.start_time == 2.0
assert resolved.end_time == 8.0
assert resolved.duration == 6.0
class TestTrimEngine(unittest.TestCase):
"""TrimEngine 单元测试."""
class TestIsValid:
"""is_valid 属性测试."""
def test_build_video_trim_with_start_and_duration(self):
"""视频裁剪:start + duration."""
trim = TrimConfig(start_time=10.0, duration=5.0)
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
self.assertIn("trim=start=10.000:duration=5.000", result)
self.assertIn("setpts=PTS-STARTPTS", result)
self.assertTrue(result.startswith("[0:v]"))
self.assertTrue(result.endswith("[v0]"))
def test_valid_duration(self):
"""时长足够有效."""
config = TrimConfig(start_time=0.0, end_time=0.0, duration=5.0)
assert config.is_valid is True
def test_build_video_trim_duration_only(self):
"""视频裁剪:只有 duration."""
trim = TrimConfig(start_time=0.0, duration=8.0)
result = TrimEngine.build_video_trim_filter("[0:v]", trim, "[v0]")
self.assertIn("trim=duration=8.000", result)
self.assertNotIn("start=", result.split("setpts")[0])
def test_zero_duration_invalid(self):
"""零时长无效."""
config = TrimConfig(duration=0.0)
assert config.is_valid is False
def test_build_audio_trim_with_start(self):
"""音频裁剪:start + duration."""
trim = TrimConfig(start_time=3.0, duration=7.0)
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
self.assertIn("atrim=start=3.000:duration=7.000", result)
self.assertIn("asetpts=PTS-STARTPTS", result)
def test_build_audio_trim_noop(self):
"""音频裁剪:noop."""
trim = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
result = TrimEngine.build_audio_trim_filter("[0:a]", trim, "[a0]")
self.assertIn("asetpts=PTS-STARTPTS", result)
self.assertNotIn("atrim=", result)
def test_resolve_segments(self):
"""多段裁剪解析."""
segments = [
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=10.0, duration=5.0), order=1),
TrimSegment(segment_id="s3", trim=TrimConfig(start_time=20.0, duration=5.0), order=2),
]
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
self.assertEqual(len(resolved), 3)
self.assertEqual(resolved[0].segment_id, "s1")
self.assertEqual(resolved[0].trim.duration, 5.0)
self.assertEqual(resolved[1].segment_id, "s2")
self.assertEqual(resolved[1].trim.start_time, 10.0)
self.assertEqual(resolved[2].trim.start_time, 20.0)
def test_resolve_segments_filter_invalid(self):
"""多段裁剪:过滤无效段."""
segments = [
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0.0, duration=5.0), order=0),
TrimSegment(segment_id="bad", trim=TrimConfig(start_time=10.0, end_time=5.0), order=1), # end < start
]
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
self.assertEqual(len(resolved), 1)
self.assertEqual(resolved[0].segment_id, "good")
def test_resolve_segments_boundary_clamp(self):
"""多段裁剪:边界钳制."""
segments = [
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=25.0, duration=10.0), order=0),
]
resolved = TrimEngine.resolve_segments(segments, asset_duration=30.0)
self.assertEqual(len(resolved), 1)
self.assertEqual(resolved[0].trim.end_time, 30.0)
self.assertAlmostEqual(resolved[0].trim.duration, 5.0, places=3)
def test_parse_segments_from_list(self):
"""从 config 解析多段配置."""
config = {
"trim_segments": [
{"segment_id": "intro", "start_time": 0, "duration": 3, "order": 0},
{"segment_id": "highlight", "start_time": 10, "duration": 5, "order": 1},
{"segment_id": "outro", "start_time": 50, "duration": 3, "order": 2},
]
}
segments = TrimEngine.parse_segments_from_config(config)
self.assertEqual(len(segments), 3)
self.assertEqual(segments[0].segment_id, "intro")
self.assertEqual(segments[1].trim.start_time, 10.0)
self.assertEqual(segments[2].trim.duration, 3.0)
def test_parse_segments_empty(self):
"""无裁剪配置 → 空列表."""
self.assertEqual(TrimEngine.parse_segments_from_config(None), [])
self.assertEqual(TrimEngine.parse_segments_from_config({}), [])
def test_parse_single_trim_legacy(self):
"""旧格式单段裁剪(trim_start/trim_duration."""
config = {"trim_start": 5.0, "trim_duration": 10.0}
segments = TrimEngine.parse_segments_from_config(config)
self.assertEqual(len(segments), 1)
self.assertEqual(segments[0].trim.start_time, 5.0)
self.assertEqual(segments[0].trim.duration, 10.0)
def test_min_duration_valid(self):
"""刚好等于最小值有效."""
config = TrimConfig(duration=MIN_TRIM_DURATION)
assert config.is_valid is True
class TestExtractTrimFromClipConfig(unittest.TestCase):
"""extract_trim_from_clip_config 单元测试."""
class TestIsNoop:
"""is_noop 属性测试."""
def test_trim_subdict(self):
"""trim 子字典."""
config = {"trim": {"start_time": 5.0, "duration": 10.0}}
result = extract_trim_from_clip_config(config)
self.assertIsNotNone(result)
self.assertEqual(result.start_time, 5.0)
self.assertEqual(result.duration, 10.0)
def test_zero_is_noop(self):
"""全零是noop."""
config = TrimConfig()
assert config.is_noop is True
def test_flat_fields(self):
"""扁平字段(trim_start/trim_end/trim_duration."""
config = {"trim_start": 2.0, "trim_end": 8.0}
result = extract_trim_from_clip_config(config)
self.assertIsNotNone(result)
self.assertEqual(result.start_time, 2.0)
self.assertEqual(result.end_time, 8.0)
def test_with_duration_not_noop(self):
"""有时长不是noop."""
config = TrimConfig(duration=10.0)
assert config.is_noop is False
def test_no_trim(self):
"""无裁剪配置."""
self.assertIsNone(extract_trim_from_clip_config(None))
self.assertIsNone(extract_trim_from_clip_config({}))
self.assertIsNone(extract_trim_from_clip_config({"other": "value"}))
def test_with_start_not_noop(self):
"""有start不是noop."""
config = TrimConfig(start_time=5.0)
assert config.is_noop is False
if __name__ == "__main__":
unittest.main()
class TestTrimFromStart:
"""trim_from_start 属性测试."""
def test_zero_start_is_from_start(self):
"""start=0是从开头裁."""
config = TrimConfig(start_time=0.0)
assert config.trim_from_start is True
def test_positive_start_not_from_start(self):
"""有start不是从开头裁."""
config = TrimConfig(start_time=5.0)
assert config.trim_from_start is False
class TestTrimSegment:
"""TrimSegment 测试."""
def test_from_dict_basic(self):
"""基本解析."""
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
assert seg.order == 0
def test_from_dict_with_order(self):
"""带order的解析."""
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
def test_from_dict_default_segment_id(self):
"""缺省segment_id时用默认值."""
seg = TrimSegment.from_dict({"duration": 5.0}, default_order=3)
assert seg.segment_id == "seg_3"
assert seg.order == 3
def test_from_dict_empty_string_segment_id(self):
"""空字符串segment_id走默认."""
seg = TrimSegment.from_dict({
"segment_id": "",
"duration": 5.0,
}, default_order=5)
assert seg.segment_id == "seg_5"