test(wave149): video_concat 单测补全 +65 #1072

Closed
xiaoxia wants to merge 1 commits from test/wave149-video-concat into develop
+387 -250
View File
@@ -1,4 +1,4 @@
"""video_concat 领域模型单测 — 纯逻辑,48个测试用例."""
"""video_concat 视频拼接领域模型单元测试."""
from __future__ import annotations
@@ -12,125 +12,192 @@ from packages.domain.video_concat import (
ConcatSegment,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
"""常量测试."""
def test_max_concat_segments(self):
"""最大拼接段数."""
assert MAX_CONCAT_SEGMENTS == 50
def test_allowed_extensions_not_empty(self):
"""支持的视频格式不为空."""
assert len(ALLOWED_VIDEO_EXTENSIONS) > 0
def test_common_formats_supported(self):
"""常见格式都支持."""
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
assert ".avi" in ALLOWED_VIDEO_EXTENSIONS
assert ".mkv" in ALLOWED_VIDEO_EXTENSIONS
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
def test_demuxer_params_not_empty(self):
"""demuxer必需参数不为空."""
assert len(CONCAT_DEMUXER_REQUIRED_PARAMS) > 0
def test_demuxer_params_include_codec(self):
"""包含编解码相关参数."""
assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "height" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS
# ── ConcatSegment 测试 ───────────────────────────────────────────────────────
class TestConcatSegmentBasics:
def test_default_values(self):
seg = ConcatSegment(video_path="test.mp4")
assert seg.video_path == "test.mp4"
class TestConcatSegment:
"""ConcatSegment 测试."""
def test_basic_creation(self):
"""基础创建."""
seg = ConcatSegment(video_path="/tmp/video.mp4")
assert seg.video_path == "/tmp/video.mp4"
assert seg.start_time == 0.0
assert seg.duration == 0.0
assert seg.has_audio is True
def test_full_params(self):
def test_full_creation(self):
"""完整字段创建."""
seg = ConcatSegment(
video_path="video.mp4",
video_path="/tmp/v.mov",
start_time=5.5,
duration=10.0,
has_audio=False,
)
assert seg.video_path == "video.mp4"
assert seg.video_path == "/tmp/v.mov"
assert seg.start_time == 5.5
assert seg.duration == 10.0
assert seg.has_audio is False
# is_valid 属性
class TestConcatSegmentFromDict:
def test_normal_dict(self):
seg = ConcatSegment.from_dict(
{
"video_path": "test.mp4",
"start_time": 2.0,
"duration": 5.0,
"has_audio": False,
}
)
assert seg.video_path == "test.mp4"
assert seg.start_time == 2.0
assert seg.duration == 5.0
assert seg.has_audio is False
def test_empty_dict(self):
seg = ConcatSegment.from_dict({})
assert seg.video_path == ""
assert seg.start_time == 0.0
assert seg.duration == 0.0
assert seg.has_audio is True
def test_none_input(self):
seg = ConcatSegment.from_dict(None)
assert seg.video_path == ""
assert seg.is_valid is False
def test_non_dict_input(self):
seg = ConcatSegment.from_dict("not a dict")
assert seg.video_path == ""
def test_start_time_negative_clamped(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": -5})
assert seg.start_time == 0.0
def test_duration_negative_clamped(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": -10})
assert seg.duration == 0.0
def test_start_time_invalid_string(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": "abc"})
assert seg.start_time == 0.0
def test_duration_invalid_string(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": "xyz"})
assert seg.duration == 0.0
def test_start_time_int_casted(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": 3})
assert seg.start_time == 3.0
def test_duration_int_casted(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": 7})
assert seg.duration == 7.0
def test_video_path_casted_to_string(self):
seg = ConcatSegment.from_dict({"video_path": 12345})
assert seg.video_path == "12345"
def test_has_audio_false(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": False})
assert seg.has_audio is False
def test_has_audio_truthy_value(self):
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": 1})
assert seg.has_audio is True
class TestConcatSegmentProperties:
def test_is_valid_with_path(self):
seg = ConcatSegment(video_path="test.mp4")
"""有视频路径是有效的."""
seg = ConcatSegment(video_path="/tmp/v.mp4")
assert seg.is_valid is True
def test_is_valid_empty_path(self):
"""空路径无效."""
seg = ConcatSegment(video_path="")
assert seg.is_valid is False
def test_effective_duration_positive(self):
seg = ConcatSegment(video_path="a.mp4", duration=10.5)
"""正的effective_duration."""
seg = ConcatSegment(video_path="v.mp4", duration=10.5)
assert seg.effective_duration == 10.5
def test_effective_duration_zero(self):
seg = ConcatSegment(video_path="a.mp4", duration=0.0)
"""duration为0时effective_duration为0."""
seg = ConcatSegment(video_path="v.mp4", duration=0)
assert seg.effective_duration == 0.0
def test_effective_duration_negative(self):
seg = ConcatSegment(video_path="a.mp4", duration=-5.0)
"""负的duration被钳制到0."""
seg = ConcatSegment(video_path="v.mp4", duration=-5)
assert seg.effective_duration == 0.0
# ── ConcatConfig 测试 ────────────────────────────────────────────────────────
# ── ConcatSegment.from_dict 测试 ─────────────────────────────────────────────
class TestConcatConfigBasics:
def test_default_values(self):
class TestConcatSegmentFromDict:
"""ConcatSegment.from_dict 工厂方法测试."""
def test_none_input(self):
"""None输入返回默认片段(空路径)."""
seg = ConcatSegment.from_dict(None)
assert seg.video_path == ""
assert seg.is_valid is False
def test_empty_dict(self):
"""空dict返回默认."""
seg = ConcatSegment.from_dict({})
assert seg.video_path == ""
def test_not_dict_input(self):
"""非dict输入安全处理."""
seg = ConcatSegment.from_dict("not_a_dict")
assert seg.video_path == ""
def test_list_input(self):
"""list输入安全处理."""
seg = ConcatSegment.from_dict([1, 2, 3])
assert seg.video_path == ""
def test_with_video_path(self):
"""带视频路径."""
seg = ConcatSegment.from_dict({"video_path": "/tmp/v.mp4"})
assert seg.video_path == "/tmp/v.mp4"
assert seg.is_valid is True
def test_with_start_time(self):
"""带start_time."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": 3.5})
assert seg.start_time == 3.5
def test_start_time_negative_clamped(self):
"""负的start_time钳制到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": -5})
assert seg.start_time == 0.0
def test_start_time_invalid_string(self):
"""无效start_time字符串回退到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": "abc"})
assert seg.start_time == 0.0
def test_with_duration(self):
"""带duration."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": 8.5})
assert seg.duration == 8.5
def test_duration_negative_clamped(self):
"""负的duration钳制到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": -10})
assert seg.duration == 0.0
def test_duration_invalid_string(self):
"""无效duration回退到0."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": "xyz"})
assert seg.duration == 0.0
def test_has_audio_true(self):
"""has_audio为True."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "has_audio": True})
assert seg.has_audio is True
def test_has_audio_false(self):
"""has_audio为False."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "has_audio": False})
assert seg.has_audio is False
def test_has_audio_default_true(self):
"""has_audio默认True."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4"})
assert seg.has_audio is True
def test_string_start_time(self):
"""字符串形式的start_time."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": "2.5"})
assert seg.start_time == 2.5
def test_int_duration(self):
"""整数duration."""
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": 10})
assert seg.duration == 10.0
# ── ConcatConfig 基础测试 ────────────────────────────────────────────────────
class TestConcatConfig:
"""ConcatConfig 基础测试."""
def test_default_creation(self):
"""默认创建."""
cfg = ConcatConfig()
assert cfg.segments == []
assert cfg.output_width == 0
@@ -141,224 +208,294 @@ class TestConcatConfigBasics:
assert cfg.transition_duration == 0.3
def test_with_segments(self):
segs = [ConcatSegment(video_path="a.mp4")]
cfg = ConcatConfig(segments=segs)
assert len(cfg.segments) == 1
assert cfg.segments[0].video_path == "a.mp4"
class TestConcatConfigFromDict:
def test_none_config(self):
cfg = ConcatConfig.from_config_dict(None)
assert cfg.segments == []
assert cfg.output_width == 0
def test_empty_dict(self):
cfg = ConcatConfig.from_config_dict({})
assert cfg.segments == []
def test_non_dict_input(self):
cfg = ConcatConfig.from_config_dict("config")
assert cfg.segments == []
def test_with_valid_segments(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "a.mp4", "duration": 10},
{"video_path": "b.mp4", "duration": 20},
],
}
)
"""带片段创建."""
segs = [ConcatSegment(video_path="v1.mp4"), ConcatSegment(video_path="v2.mp4")]
cfg = ConcatConfig(segments=segs, output_width=1920, output_height=1080)
assert len(cfg.segments) == 2
assert cfg.segments[0].video_path == "a.mp4"
assert cfg.segments[1].video_path == "b.mp4"
def test_skips_empty_video_path(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "a.mp4"},
{"video_path": ""},
{"video_path": "b.mp4"},
],
}
)
assert len(cfg.segments) == 2
def test_skips_invalid_segment_dict(self):
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "a.mp4"},
"not a dict",
{"video_path": "b.mp4"},
],
}
)
assert len(cfg.segments) == 2
def test_segments_not_a_list(self):
cfg = ConcatConfig.from_config_dict({"segments": "not a list"})
assert cfg.segments == []
def test_output_params(self):
cfg = ConcatConfig.from_config_dict(
{
"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_width_negative_clamped(self):
cfg = ConcatConfig.from_config_dict({"output_width": -100})
assert cfg.output_width == 0
# 属性测试
def test_output_height_invalid_string(self):
cfg = ConcatConfig.from_config_dict({"output_height": "abc"})
assert cfg.output_height == 0
def test_output_fps_invalid_string(self):
cfg = ConcatConfig.from_config_dict({"output_fps": "xyz"})
assert cfg.output_fps == 0.0
def test_transition_params(self):
cfg = ConcatConfig.from_config_dict(
{
"transition": "crossfade",
"transition_duration": 1.0,
}
)
assert cfg.transition == "crossfade"
assert cfg.transition_duration == 1.0
def test_transition_duration_minimum(self):
cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01})
assert cfg.transition_duration == 0.1
def test_transition_duration_negative(self):
cfg = ConcatConfig.from_config_dict({"transition_duration": -1})
assert cfg.transition_duration == 0.1
def test_force_reencode_false_by_default(self):
cfg = ConcatConfig.from_config_dict({})
assert cfg.force_reencode is False
class TestConcatConfigProperties:
def test_has_effect_two_segments(self):
def test_has_effect_with_two_segments(self):
"""2个以上有效片段has_effect为True."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4"),
ConcatSegment(video_path="b.mp4"),
ConcatSegment(video_path="v1.mp4"),
ConcatSegment(video_path="v2.mp4"),
]
)
assert cfg.has_effect is True
def test_has_effect_one_segment(self):
cfg = ConcatConfig(segments=[ConcatSegment(video_path="a.mp4")])
def test_has_effect_with_one_segment(self):
"""只有1个有效片段has_effect为False."""
cfg = ConcatConfig(segments=[ConcatSegment(video_path="v1.mp4")])
assert cfg.has_effect is False
def test_has_effect_empty(self):
def test_has_effect_with_no_segments(self):
"""空片段has_effect为False."""
cfg = ConcatConfig()
assert cfg.has_effect is False
def test_has_effect_skips_invalid(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4"),
ConcatSegment(video_path=""),
ConcatSegment(video_path="b.mp4"),
]
)
assert cfg.has_effect is True
def test_valid_segment_count(self):
"""有效片段计数."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4"),
ConcatSegment(video_path=""),
ConcatSegment(video_path="b.mp4"),
ConcatSegment(video_path="v1.mp4"),
ConcatSegment(video_path=""), # 无效
ConcatSegment(video_path="v2.mp4"),
]
)
assert cfg.valid_segment_count == 2
def test_first_valid_segment(self):
def test_total_segments_alias(self):
"""total_segments是valid_segment_count的别名."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""),
ConcatSegment(video_path="first.mp4"),
ConcatSegment(video_path="v1.mp4"),
ConcatSegment(video_path="v2.mp4"),
]
)
assert cfg.total_segments == cfg.valid_segment_count
assert cfg.total_segments == 2
def test_first_valid_segment(self):
"""第一个有效片段."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""), # 无效
ConcatSegment(video_path="first_valid.mp4"),
ConcatSegment(video_path="second.mp4"),
]
)
assert cfg.first_valid_segment is not None
assert cfg.first_valid_segment.video_path == "first.mp4"
first = cfg.first_valid_segment
assert first is not None
assert first.video_path == "first_valid.mp4"
def test_first_valid_segment_none_when_all_empty(self):
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path=""),
ConcatSegment(video_path=""),
]
)
def test_first_valid_segment_none(self):
"""无有效片段时first_valid_segment为None."""
cfg = ConcatConfig(segments=[ConcatSegment(video_path="")])
assert cfg.first_valid_segment is None
def test_first_valid_segment_empty_list(self):
def test_first_valid_segment_empty(self):
"""空列表时为None."""
cfg = ConcatConfig()
assert cfg.first_valid_segment is None
def test_estimated_total_duration(self):
"""估算总时长."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="a.mp4", duration=10.0),
ConcatSegment(video_path="b.mp4", duration=20.0),
ConcatSegment(video_path="c.mp4", duration=0.0),
ConcatSegment(video_path="v1.mp4", duration=10.0),
ConcatSegment(video_path="v2.mp4", duration=5.5),
ConcatSegment(video_path="v3.mp4", duration=0), # 不计入
]
)
assert cfg.estimated_total_duration == 30.0
assert cfg.estimated_total_duration == pytest.approx(15.5)
def test_estimated_total_duration_empty(self):
"""空片段时长为0."""
cfg = ConcatConfig()
assert cfg.estimated_total_duration == 0.0
def test_estimated_total_duration_skips_invalid(self):
"""跳过无效片段."""
cfg = ConcatConfig(
segments=[
ConcatSegment(video_path="", duration=10.0),
ConcatSegment(video_path="a.mp4", duration=5.0),
ConcatSegment(video_path="", duration=100), # 无效,跳过
ConcatSegment(video_path="v1.mp4", duration=5.0),
]
)
assert cfg.estimated_total_duration == 5.0
class TestConcatConfigClampSegments:
def test_clamp_when_over_max(self):
segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(100)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(50)
assert len(cfg.segments) == 50
assert cfg.segments[0].video_path == "s0.mp4"
assert cfg.segments[-1].video_path == "s49.mp4"
# ── ConcatConfig.from_config_dict 测试 ───────────────────────────────────────
def test_no_clamp_when_under_max(self):
segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(10)]
class TestConcatConfigFromConfigDict:
"""ConcatConfig.from_config_dict 工厂方法测试."""
def test_none_config(self):
"""None返回默认配置."""
cfg = ConcatConfig.from_config_dict(None)
assert cfg.segments == []
assert cfg.output_width == 0
def test_empty_config(self):
"""空dict返回默认."""
cfg = ConcatConfig.from_config_dict({})
assert cfg.segments == []
def test_not_dict(self):
"""非dict安全处理."""
cfg = ConcatConfig.from_config_dict("not_dict")
assert cfg.segments == []
def test_with_segments(self):
"""带片段列表."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "v1.mp4", "duration": 10},
{"video_path": "v2.mp4", "start_time": 2},
]
}
)
assert len(cfg.segments) == 2
assert cfg.segments[0].video_path == "v1.mp4"
assert cfg.segments[0].duration == 10.0
assert cfg.segments[1].start_time == 2.0
def test_segments_not_list(self):
"""segments不是list时忽略."""
cfg = ConcatConfig.from_config_dict({"segments": "not_a_list"})
assert cfg.segments == []
def test_skips_segments_without_path(self):
"""跳过没有video_path的片段."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "v1.mp4"},
{"duration": 5}, # 没有video_path
{"video_path": ""}, # 空path
]
}
)
assert len(cfg.segments) == 1
assert cfg.segments[0].video_path == "v1.mp4"
def test_skips_non_dict_segments(self):
"""跳过非dict片段."""
cfg = ConcatConfig.from_config_dict(
{
"segments": [
{"video_path": "v1.mp4"},
"not_a_dict",
None,
123,
]
}
)
assert len(cfg.segments) == 1
def test_output_dimensions(self):
"""输出尺寸."""
cfg = ConcatConfig.from_config_dict(
{
"output_width": 1920,
"output_height": 1080,
}
)
assert cfg.output_width == 1920
assert cfg.output_height == 1080
def test_output_dimensions_negative_clamped(self):
"""负的尺寸钳制到0."""
cfg = ConcatConfig.from_config_dict(
{
"output_width": -100,
"output_height": -50,
}
)
assert cfg.output_width == 0
assert cfg.output_height == 0
def test_output_dimensions_invalid(self):
"""无效尺寸回退到0."""
cfg = ConcatConfig.from_config_dict(
{
"output_width": "abc",
"output_height": "xyz",
}
)
assert cfg.output_width == 0
assert cfg.output_height == 0
def test_output_fps(self):
"""输出帧率."""
cfg = ConcatConfig.from_config_dict({"output_fps": 30.0})
assert cfg.output_fps == 30.0
def test_output_fps_negative_clamped(self):
"""负帧率钳制到0."""
cfg = ConcatConfig.from_config_dict({"output_fps": -5})
assert cfg.output_fps == 0.0
def test_force_reencode(self):
"""强制重新编码."""
cfg = ConcatConfig.from_config_dict({"force_reencode": True})
assert cfg.force_reencode is True
def test_force_reencode_default_false(self):
"""默认不强制重编码."""
cfg = ConcatConfig.from_config_dict({})
assert cfg.force_reencode is False
def test_transition(self):
"""转场效果."""
cfg = ConcatConfig.from_config_dict({"transition": "crossfade"})
assert cfg.transition == "crossfade"
def test_transition_default_none(self):
"""默认转场none."""
cfg = ConcatConfig.from_config_dict({})
assert cfg.transition == "none"
def test_transition_duration(self):
"""转场时长."""
cfg = ConcatConfig.from_config_dict({"transition_duration": 1.0})
assert cfg.transition_duration == 1.0
def test_transition_duration_min(self):
"""转场时长最小值0.1."""
cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01})
assert cfg.transition_duration == 0.1
def test_transition_duration_invalid(self):
"""无效转场时长回退到默认."""
cfg = ConcatConfig.from_config_dict({"transition_duration": "invalid"})
assert cfg.transition_duration == 0.3
# ── clamp_segments 测试 ──────────────────────────────────────────────────────
class TestClampSegments:
"""clamp_segments 截断测试."""
def test_under_limit_no_change(self):
"""低于上限时不截断."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(10)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(50)
cfg.clamp_segments(max_segments=50)
assert len(cfg.segments) == 10
def test_default_max_constant(self):
assert MAX_CONCAT_SEGMENTS == 50
def test_over_limit_truncated(self):
"""超过上限时截断."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(100)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(max_segments=30)
assert len(cfg.segments) == 30
assert cfg.segments[0].video_path == "v0.mp4"
assert cfg.segments[-1].video_path == "v29.mp4"
def test_default_max_uses_constant(self):
"""默认max_segments使用常量."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(100)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments() # 默认MAX_CONCAT_SEGMENTS
assert len(cfg.segments) == MAX_CONCAT_SEGMENTS
class TestConstants:
def test_allowed_extensions(self):
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
def test_empty_segments(self):
"""空列表不报错."""
cfg = ConcatConfig()
cfg.clamp_segments()
assert cfg.segments == []
def test_demuxer_params(self):
assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS
assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS
def test_at_limit_stays(self):
"""刚好在上限时不变."""
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(50)]
cfg = ConcatConfig(segments=segs)
cfg.clamp_segments(max_segments=50)
assert len(cfg.segments) == 50