241760ef39
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 37s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 4m21s
CI/CD Pipeline / Integration Tests (push) Failing after 4m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
269 lines
11 KiB
Python
Executable File
269 lines
11 KiB
Python
Executable File
"""裁剪引擎单元测试."""
|
||
|
||
import sys
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
# 确保 apps/worker 在路径中
|
||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "apps" / "worker"))
|
||
|
||
from video_processing.trim_engine import (
|
||
MIN_TRIM_DURATION,
|
||
TrimConfig,
|
||
TrimEngine,
|
||
TrimSegment,
|
||
extract_trim_from_clip_config,
|
||
)
|
||
|
||
|
||
class TestTrimConfig(unittest.TestCase):
|
||
"""TrimConfig 单元测试."""
|
||
|
||
def test_from_dict_none(self):
|
||
"""空字典返回 None(不裁剪)."""
|
||
self.assertIsNone(TrimConfig.from_dict(None))
|
||
self.assertIsNone(TrimConfig.from_dict({}))
|
||
|
||
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_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_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_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_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_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_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_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_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_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)
|
||
|
||
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_is_noop(self):
|
||
"""is_noop 判断."""
|
||
noop = TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||
self.assertTrue(noop.is_noop)
|
||
|
||
not_noop = TrimConfig(start_time=5.0, duration=10.0)
|
||
self.assertFalse(not_noop.is_noop)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
|
||
class TestTrimEngine(unittest.TestCase):
|
||
"""TrimEngine 单元测试."""
|
||
|
||
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_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_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)
|
||
|
||
|
||
class TestExtractTrimFromClipConfig(unittest.TestCase):
|
||
"""extract_trim_from_clip_config 单元测试."""
|
||
|
||
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_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_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"}))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|