c31bc96855
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
502 lines
17 KiB
Python
Executable File
502 lines
17 KiB
Python
Executable File
"""video_concat 视频拼接领域模型单元测试."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from packages.domain.video_concat import (
|
|
ALLOWED_VIDEO_EXTENSIONS,
|
|
CONCAT_DEMUXER_REQUIRED_PARAMS,
|
|
MAX_CONCAT_SEGMENTS,
|
|
ConcatConfig,
|
|
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 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_creation(self):
|
|
"""完整字段创建."""
|
|
seg = ConcatSegment(
|
|
video_path="/tmp/v.mov",
|
|
start_time=5.5,
|
|
duration=10.0,
|
|
has_audio=False,
|
|
)
|
|
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 属性
|
|
|
|
def test_is_valid_with_path(self):
|
|
"""有视频路径是有效的."""
|
|
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):
|
|
"""正的effective_duration."""
|
|
seg = ConcatSegment(video_path="v.mp4", duration=10.5)
|
|
assert seg.effective_duration == 10.5
|
|
|
|
def test_effective_duration_zero(self):
|
|
"""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):
|
|
"""负的duration被钳制到0."""
|
|
seg = ConcatSegment(video_path="v.mp4", duration=-5)
|
|
assert seg.effective_duration == 0.0
|
|
|
|
|
|
# ── ConcatSegment.from_dict 测试 ─────────────────────────────────────────────
|
|
|
|
|
|
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
|
|
assert cfg.output_height == 0
|
|
assert cfg.output_fps == 0.0
|
|
assert cfg.force_reencode is False
|
|
assert cfg.transition == "none"
|
|
assert cfg.transition_duration == 0.3
|
|
|
|
def test_with_segments(self):
|
|
"""带片段创建."""
|
|
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.output_width == 1920
|
|
assert cfg.output_height == 1080
|
|
|
|
# 属性测试
|
|
|
|
def test_has_effect_with_two_segments(self):
|
|
"""2个以上有效片段has_effect为True."""
|
|
cfg = ConcatConfig(
|
|
segments=[
|
|
ConcatSegment(video_path="v1.mp4"),
|
|
ConcatSegment(video_path="v2.mp4"),
|
|
]
|
|
)
|
|
assert cfg.has_effect is True
|
|
|
|
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_with_no_segments(self):
|
|
"""空片段has_effect为False."""
|
|
cfg = ConcatConfig()
|
|
assert cfg.has_effect is False
|
|
|
|
def test_valid_segment_count(self):
|
|
"""有效片段计数."""
|
|
cfg = ConcatConfig(
|
|
segments=[
|
|
ConcatSegment(video_path="v1.mp4"),
|
|
ConcatSegment(video_path=""), # 无效
|
|
ConcatSegment(video_path="v2.mp4"),
|
|
]
|
|
)
|
|
assert cfg.valid_segment_count == 2
|
|
|
|
def test_total_segments_alias(self):
|
|
"""total_segments是valid_segment_count的别名."""
|
|
cfg = ConcatConfig(
|
|
segments=[
|
|
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"),
|
|
]
|
|
)
|
|
first = cfg.first_valid_segment
|
|
assert first is not None
|
|
assert first.video_path == "first_valid.mp4"
|
|
|
|
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(self):
|
|
"""空列表时为None."""
|
|
cfg = ConcatConfig()
|
|
assert cfg.first_valid_segment is None
|
|
|
|
def test_estimated_total_duration(self):
|
|
"""估算总时长."""
|
|
cfg = ConcatConfig(
|
|
segments=[
|
|
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 == 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=100), # 无效,跳过
|
|
ConcatSegment(video_path="v1.mp4", duration=5.0),
|
|
]
|
|
)
|
|
assert cfg.estimated_total_duration == 5.0
|
|
|
|
|
|
# ── ConcatConfig.from_config_dict 测试 ───────────────────────────────────────
|
|
|
|
|
|
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(max_segments=50)
|
|
assert len(cfg.segments) == 10
|
|
|
|
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
|
|
|
|
def test_empty_segments(self):
|
|
"""空列表不报错."""
|
|
cfg = ConcatConfig()
|
|
cfg.clamp_segments()
|
|
assert cfg.segments == []
|
|
|
|
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
|