"""VideoComposeService 单元测试. 使用 stub 仓储替代真实数据库,测试 FFmpeg 命令生成和校验逻辑。 """ from __future__ import annotations import sys from pathlib import Path from unittest import TestCase # 修正 import 路径 sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from app.services.video_compose_service import ( ComposeCommand, VideoComposeService, _build_concat_filter, _build_xfade_filter, _chain_filters, ) from packages.domain.edit_plan import EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClipStatus # ── Stub 实体 ───────────────────────────────────────────────────────────────── class _StubClip: """EditPlanClip 的轻量替身。""" def __init__( self, clip_id: str = "clip-1", plan_id: str = "plan-1", clip_type: str = "main", order: int = 0, asset_id: str = "", duration: float = 5.0, start_time: float = 0.0, transition_effect: str = "cut", status: EditPlanClipStatus = EditPlanClipStatus.READY, ): self.id = clip_id self.plan_id = plan_id self.clip_type = clip_type self.order = order self.asset_id = asset_id self.duration = duration self.start_time = start_time self.transition_effect = transition_effect self.status = status self.template_clip_config_id = "" self.text_content = "" self.config = {} class _StubPlan: """EditPlan 的轻量替身。""" def __init__( self, plan_id: str = "plan-1", status: EditPlanStatus = EditPlanStatus.EDITING, ): self.id = plan_id self.template_id = "tpl-1" self.name = "测试计划" self.status = status self.total_duration = 0.0 self.config = {} # ── Stub 仓储 ───────────────────────────────────────────────────────────────── class _StubPlanRepo: def __init__(self, plans: dict[str, _StubPlan] | None = None): self._plans = plans or {} def get(self, plan_id: str): return self._plans.get(plan_id) def list_by_project(self, *args, **kwargs): return list(self._plans.values()) class _StubClipRepo: def __init__(self, clips: list[_StubClip] | None = None): self._clips = {c.id: c for c in (clips or [])} self._by_plan: dict[str, list[_StubClip]] = {} for c in clips or []: self._by_plan.setdefault(c.plan_id, []).append(c) def get(self, clip_id: str): return self._clips.get(clip_id) def list_by_plan(self, plan_id: str, skip: int = 0, limit: int = 100): clips = self._by_plan.get(plan_id, []) return clips[skip : skip + limit] # ── 辅助工厂 ────────────────────────────────────────────────────────────────── def _make_service( plan: _StubPlan | None = None, clips: list[_StubClip] | None = None, ) -> VideoComposeService: """创建注入 stub 仓储的 VideoComposeService。""" svc = VideoComposeService.__new__(VideoComposeService) svc._db = None # type: ignore[assignment] svc._plan_repo = _StubPlanRepo({plan.id: plan} if plan else {}) # type: ignore[assignment] svc._clip_repo = _StubClipRepo(clips or []) # type: ignore[assignment] return svc def _make_ready_clip( clip_id: str = "clip-1", plan_id: str = "plan-1", order: int = 0, duration: float = 5.0, asset_id: str = "assets/video.mp4", transition: str = "cut", ) -> _StubClip: return _StubClip( clip_id=clip_id, plan_id=plan_id, order=order, asset_id=asset_id, duration=duration, transition_effect=transition, status=EditPlanClipStatus.READY, ) # ── 测试用例 ────────────────────────────────────────────────────────────────── class TestValidateCompose(TestCase): """validate_compose 校验逻辑测试。""" def test_plan_not_found(self): """计划不存在 → 校验失败。""" svc = _make_service() result = svc.validate_compose("nonexistent") self.assertFalse(result.valid) self.assertIn("剪辑计划不存在", result.errors[0]) def test_wrong_status(self): """计划状态不是 editing/rendering → 校验失败。""" plan = _StubPlan(status=EditPlanStatus.DRAFT) clips = [_make_ready_clip()] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertFalse(result.valid) self.assertTrue(any("状态不正确" in e for e in result.errors)) def test_no_clips(self): """计划没有片段 → 校验失败。""" plan = _StubPlan(status=EditPlanStatus.EDITING) svc = _make_service(plan, []) result = svc.validate_compose(plan.id) self.assertFalse(result.valid) self.assertIn("计划没有任何片段", result.errors[0]) def test_no_ready_clips(self): """没有 ready 状态的片段 → 校验失败。""" plan = _StubPlan(status=EditPlanStatus.EDITING) clips = [ _StubClip( clip_id="c1", plan_id=plan.id, status=EditPlanClipStatus.PENDING, asset_id="a.mp4", ) ] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertFalse(result.valid) self.assertTrue(any("没有就绪" in e for e in result.errors)) def test_ready_clip_without_asset(self): """ready 片段没有 asset_id → 校验失败。""" plan = _StubPlan(status=EditPlanStatus.EDITING) clips = [ _StubClip( clip_id="c1", plan_id=plan.id, status=EditPlanClipStatus.READY, asset_id="", ) ] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertFalse(result.valid) self.assertTrue(any("没有分配素材" in e for e in result.errors)) def test_valid_single_clip(self): """单个 ready 片段 → 校验通过。""" plan = _StubPlan(status=EditPlanStatus.EDITING) clips = [_make_ready_clip(plan_id=plan.id)] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertTrue(result.valid) self.assertEqual(result.ready_clip_count, 1) self.assertEqual(result.total_clip_count, 1) self.assertEqual(len(result.errors), 0) def test_valid_multiple_clips(self): """多个 ready 片段 → 校验通过。""" plan = _StubPlan(status=EditPlanStatus.EDITING) clips = [ _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0), _make_ready_clip(clip_id="c2", plan_id=plan.id, order=1), _make_ready_clip(clip_id="c3", plan_id=plan.id, order=2), ] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertTrue(result.valid) self.assertEqual(result.ready_clip_count, 3) def test_rendering_status_also_valid(self): """rendering 状态也允许合成。""" plan = _StubPlan(status=EditPlanStatus.RENDERING) clips = [_make_ready_clip(plan_id=plan.id)] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertTrue(result.valid) def test_mixed_statuses_with_pending_warning(self): """混合状态:ready + pending → 通过但有警告。""" plan = _StubPlan(status=EditPlanStatus.EDITING) clips = [ _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0), _StubClip( clip_id="c2", plan_id=plan.id, order=1, status=EditPlanClipStatus.PENDING, asset_id="b.mp4", ), ] svc = _make_service(plan, clips) result = svc.validate_compose(plan.id) self.assertTrue(result.valid) self.assertEqual(result.ready_clip_count, 1) self.assertEqual(result.total_clip_count, 2) self.assertTrue(any("pending" in w for w in result.warnings)) class TestBuildComposeCommand(TestCase): """build_compose_command 命令生成测试。""" def test_plan_not_found_raises(self): """计划不存在 → ValueError。""" svc = _make_service() with self.assertRaises(ValueError): svc.build_compose_command("nonexistent", "/tmp/out.mp4") def test_no_clips_raises(self): """没有片段 → ValueError。""" plan = _StubPlan() svc = _make_service(plan, []) with self.assertRaises(ValueError): svc.build_compose_command(plan.id, "/tmp/out.mp4") def test_no_ready_clips_raises(self): """没有 ready 片段 → ValueError。""" plan = _StubPlan() clips = [ _StubClip( clip_id="c1", plan_id=plan.id, status=EditPlanClipStatus.PENDING, asset_id="a.mp4", ) ] svc = _make_service(plan, clips) with self.assertRaises(ValueError): svc.build_compose_command(plan.id, "/tmp/out.mp4") def test_single_clip_command(self): """单片段命令生成。""" plan = _StubPlan() clips = [_make_ready_clip(plan_id=plan.id, duration=10.0)] svc = _make_service(plan, clips) cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") self.assertIsInstance(cmd, ComposeCommand) self.assertEqual(cmd.input_paths, ["assets/video.mp4"]) self.assertEqual(cmd.output_path, "/tmp/out.mp4") self.assertEqual(cmd.estimated_duration, 10.0) self.assertEqual(len(cmd.clip_chains), 1) self.assertIn("ffmpeg", cmd.command[0]) self.assertIn("-filter_complex", cmd.command) def test_multi_clip_concat_command(self): """多片段 concat 命令生成。""" plan = _StubPlan() clips = [ _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0), _make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0), ] svc = _make_service(plan, clips) cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") self.assertEqual(len(cmd.input_paths), 2) self.assertEqual(cmd.estimated_duration, 13.0) self.assertIn("concat", cmd.filter_complex) self.assertIn("[outv]", cmd.filter_complex) def test_multi_clip_xfade_command(self): """多片段 xfade 转场命令生成。""" plan = _StubPlan() clips = [ _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0, transition="fade"), _make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0, transition="cut"), ] svc = _make_service(plan, clips) cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") self.assertEqual(len(cmd.input_paths), 2) self.assertIn("xfade", cmd.filter_complex) self.assertIn("transition=fade", cmd.filter_complex) # 总时长应减去转场时长 self.assertLess(cmd.estimated_duration, 13.0) def test_custom_output_params(self): """自定义输出参数。""" plan = _StubPlan() clips = [_make_ready_clip(plan_id=plan.id)] svc = _make_service(plan, clips) cmd = svc.build_compose_command( plan.id, "/tmp/out.mp4", output_width=1920, output_height=1080, codec="libx265", crf=28, ) self.assertIn("-crf", cmd.command) crf_idx = cmd.command.index("-crf") self.assertEqual(cmd.command[crf_idx + 1], "28") def test_filter_chain_contains_scale_and_crop(self): """滤镜链包含 scale 和 crop。""" plan = _StubPlan() clips = [_make_ready_clip(plan_id=plan.id)] svc = _make_service(plan, clips) cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") chain = cmd.clip_chains[0] filter_text = ",".join(chain.filters) self.assertIn("scale=", filter_text) self.assertIn("crop=", filter_text) self.assertIn("trim=", filter_text) def test_start_time_offset(self): """片段 start_time > 0 时生成 setpts 偏移。""" plan = _StubPlan() clips = [_make_ready_clip(plan_id=plan.id, duration=5.0)] clips[0].start_time = 2.5 svc = _make_service(plan, clips) cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4") chain = cmd.clip_chains[0] filter_text = ",".join(chain.filters) self.assertIn("2.5/TB", filter_text) class TestBuildSingleClipCommand(TestCase): """build_single_clip_command 测试。""" def test_clip_not_found_raises(self): """片段不存在 → ValueError。""" svc = _make_service() with self.assertRaises(ValueError): svc.build_single_clip_command("nonexistent", "/tmp/out.mp4") def test_no_asset_raises(self): """片段没有素材 → ValueError。""" plan = _StubPlan() clips = [_StubClip(clip_id="c1", plan_id=plan.id, asset_id="")] svc = _make_service(plan, clips) with self.assertRaises(ValueError): svc.build_single_clip_command("c1", "/tmp/out.mp4") def test_single_clip_preview(self): """单片段预览命令。""" plan = _StubPlan() clips = [_make_ready_clip(clip_id="c1", plan_id=plan.id)] svc = _make_service(plan, clips) cmd = svc.build_single_clip_command("c1", "/tmp/preview.mp4") self.assertEqual(cmd.output_path, "/tmp/preview.mp4") self.assertEqual(len(cmd.clip_chains), 1) # filter_complex 字段是原始滤镜字符串(不含 [outv] 标签) self.assertIn("scale=", cmd.filter_complex) # 完整命令中包含 [outv] self.assertIn("[outv]", " ".join(cmd.command)) class TestGetComposeStatus(TestCase): """get_compose_status 测试。""" def test_plan_not_found_raises(self): """计划不存在 → ValueError。""" svc = _make_service() with self.assertRaises(ValueError): svc.get_compose_status("nonexistent") def test_status_summary(self): """状态摘要正确。""" plan = _StubPlan(status=EditPlanStatus.EDITING) clips = [ _make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0), _StubClip( clip_id="c2", plan_id=plan.id, order=1, status=EditPlanClipStatus.PENDING, asset_id="b.mp4", ), _StubClip( clip_id="c3", plan_id=plan.id, order=2, status=EditPlanClipStatus.RENDERED, asset_id="c.mp4", duration=3.0, ), ] svc = _make_service(plan, clips) status = svc.get_compose_status(plan.id) self.assertEqual(status["plan_id"], plan.id) self.assertEqual(status["plan_status"], "editing") self.assertEqual(status["total_clips"], 3) self.assertEqual(status["ready_clips"], 1) self.assertEqual(status["pending_clips"], 1) self.assertEqual(status["rendered_clips"], 1) self.assertEqual(status["total_duration"], 13.0) # 5.0 + 5.0 + 3.0(所有有 duration 的片段) self.assertTrue(status["can_compose"]) class TestChainFilters(TestCase): """_chain_filters 辅助函数测试。""" def test_basic_chain(self): """基本滤镜链。""" result = _chain_filters(["scale=1280:720", "crop=1280:720"], "v0") self.assertEqual(result, "[0:v]scale=1280:720,crop=1280:720[v0]") def test_empty_filters(self): """空滤镜列表。""" result = _chain_filters([], "v0") self.assertEqual(result, "[0:v][v0]") class TestBuildConcatFilter(TestCase): """_build_concat_filter 测试。""" def test_single_clip(self): """单片段 concat。""" from app.services.video_compose_service import ClipFilterChain chains = [ ClipFilterChain( clip_id="c1", input_index=0, video_label="v0", audio_label="a0", filters=["scale=1280:720", "trim=0:5"], duration=5.0, ) ] filter_str, duration = _build_concat_filter(chains) self.assertIn("concat=n=1", filter_str) self.assertEqual(duration, 5.0) def test_multi_clip(self): """多片段 concat。""" from app.services.video_compose_service import ClipFilterChain chains = [ ClipFilterChain( clip_id="c1", input_index=0, video_label="v0", audio_label=None, filters=["scale=1280:720"], duration=5.0, ), ClipFilterChain( clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=["scale=1280:720"], duration=8.0, ), ] filter_str, duration = _build_concat_filter(chains) self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str) self.assertEqual(duration, 13.0) class TestBuildXfadeFilter(TestCase): """_build_xfade_filter 测试。""" def test_two_clips_with_fade(self): """两个片段 + fade 转场。""" from app.services.video_compose_service import ClipFilterChain chains = [ ClipFilterChain( clip_id="c1", input_index=0, video_label="v0", audio_label=None, filters=["scale=1280:720"], duration=5.0, ), ClipFilterChain( clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=["scale=1280:720"], duration=8.0, ), ] filter_str, duration = _build_xfade_filter(chains, transition_duration=0.5, transitions=["cut", "fade"]) self.assertIn("xfade=transition=fade", filter_str) self.assertIn("duration=0.5", filter_str) self.assertIn("[outv]", filter_str) # 总时长 = 5 + 8 - 0.5 = 12.5 self.assertAlmostEqual(duration, 12.5, places=2) def test_three_clips_chained_xfade(self): """三个片段链式 xfade。""" from app.services.video_compose_service import ClipFilterChain chains = [ ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label=None, filters=[], duration=5.0), ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=5.0), ClipFilterChain(clip_id="c3", input_index=2, video_label="v2", audio_label=None, filters=[], duration=5.0), ] filter_str, duration = _build_xfade_filter( chains, transition_duration=0.5, transitions=["cut", "fade", "slide_left"] ) self.assertIn("xfade=transition=fade", filter_str) self.assertIn("xfade=transition=slideleft", filter_str) # 总时长 = 15 - 0.5*2 = 14.0 self.assertAlmostEqual(duration, 14.0, places=2) class TestHasAudioTitleSubtitleFix(TestCase): """P0 修复验证:title/subtitle 片段不应有音频流。""" def _make_clip(self, clip_id, clip_type, **kwargs): """创建测试用 stub clip。""" return _StubClip(clip_id=clip_id, clip_type=clip_type, **kwargs) def test_title_clip_has_no_audio_label(self): """title 类型片段的 audio_label 应为 None。""" clip = self._make_clip("c1", "title") chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25) self.assertIsNone(chain.audio_label, "title 片段不应有音频标签") def test_subtitle_clip_has_no_audio_label(self): """subtitle 类型片段的 audio_label 应为 None。""" clip = self._make_clip("c1", "subtitle") chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25) self.assertIsNone(chain.audio_label, "subtitle 片段不应有音频标签") def test_main_clip_has_audio_label(self): """main 类型片段应有音频标签。""" clip = self._make_clip("c1", "main") chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25) self.assertEqual(chain.audio_label, "a0") def test_intro_clip_has_audio_label(self): """intro 类型片段应有音频标签。""" clip = self._make_clip("c1", "intro") chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25) self.assertEqual(chain.audio_label, "a0") def test_has_audio_false_when_only_title_subtitle(self): """当所有片段都是 title/subtitle 时,_has_audio 应返回 False。""" chains = [ VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25), VideoComposeService._build_clip_filter(self._make_clip("c2", "subtitle"), 1, 1280, 720, 25), ] self.assertFalse(VideoComposeService._has_audio(chains)) def test_has_audio_true_when_mixed_clips(self): """混合片段(含 main)时,_has_audio 应返回 True。""" chains = [ VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25), VideoComposeService._build_clip_filter(self._make_clip("c2", "main"), 1, 1280, 720, 25), ] self.assertTrue(VideoComposeService._has_audio(chains)) def test_empty_clip_type_has_audio(self): """clip_type 为空字符串时,应有音频标签(保守策略)。""" clip = self._make_clip("c1", "") chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25) self.assertEqual(chain.audio_label, "a0") if __name__ == "__main__": import unittest unittest.main()