""" EditPlanService 单元测试 覆盖(35+ 测试用例): - 计划 CRUD:创建、查询、更新、删除 - 状态机流转:合法流转、非法流转、幂等流转 - 片段管理:创建、更新、删除、分配素材 - 渲染生成流程:can_generate、mark_clips_ready、get_generation_status - 异常处理:不存在、参数校验 """ from __future__ import annotations import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, List, Optional from unittest.mock import MagicMock os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus # --------------------------------------------------------------------------- # Stub Repositories # --------------------------------------------------------------------------- class StubEditPlanRepository: """内存中的 EditPlan 仓储 stub""" def __init__(self) -> None: self._plans: dict[str, EditPlan] = {} self._counter = 0 def _next_id(self) -> str: self._counter += 1 return f"plan-{self._counter:03d}" def list_all( self, *, status: Optional[EditPlanStatus] = None, skip: int = 0, limit: int = 50, ) -> List[EditPlan]: items = list(self._plans.values()) if status: items = [p for p in items if p.status == status] return items[skip : skip + limit] def list_by_template( self, template_id: str, *, status: Optional[EditPlanStatus] = None, skip: int = 0, limit: int = 50, ) -> List[EditPlan]: items = [p for p in self._plans.values() if p.template_id == template_id] if status: items = [p for p in items if p.status == status] return items[skip : skip + limit] def get(self, plan_id: str) -> Optional[EditPlan]: return self._plans.get(plan_id) def create(self, plan: EditPlan) -> EditPlan: if not plan.id: plan = EditPlan( id=self._next_id(), template_id=plan.template_id, name=plan.name, status=plan.status, total_duration=plan.total_duration, config=plan.config, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) self._plans[plan.id] = plan return plan def update(self, plan: EditPlan) -> EditPlan: self._plans[plan.id] = plan return plan def delete(self, plan_id: str) -> bool: return self._plans.pop(plan_id, None) is not None def count( self, *, status: Optional[EditPlanStatus] = None, ) -> int: items = list(self._plans.values()) if status: items = [p for p in items if p.status == status] return len(items) class StubEditPlanClipRepository: """内存中的 EditPlanClip 仓储 stub""" def __init__(self) -> None: self._clips: dict[str, EditPlanClip] = {} self._counter = 0 def _next_id(self) -> str: self._counter += 1 return f"clip-{self._counter:03d}" def list_by_plan( self, plan_id: str, *, status: Optional[EditPlanClipStatus] = None, skip: int = 0, limit: int = 100, ) -> List[EditPlanClip]: items = [c for c in self._clips.values() if c.plan_id == plan_id] if status: items = [c for c in items if c.status == status] items.sort(key=lambda c: c.order) return items[skip : skip + limit] def get(self, clip_id: str) -> Optional[EditPlanClip]: return self._clips.get(clip_id) def create(self, clip: EditPlanClip) -> EditPlanClip: if not clip.id: clip = EditPlanClip( id=self._next_id(), plan_id=clip.plan_id, clip_type=clip.clip_type, order=clip.order, template_clip_config_id=clip.template_clip_config_id, asset_id=clip.asset_id, text_content=clip.text_content, start_time=clip.start_time, duration=clip.duration, transition_effect=clip.transition_effect, status=clip.status, config=clip.config, created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), ) self._clips[clip.id] = clip return clip def update(self, clip: EditPlanClip) -> EditPlanClip: self._clips[clip.id] = clip return clip def delete(self, clip_id: str) -> bool: return self._clips.pop(clip_id, None) is not None def delete_by_plan(self, plan_id: str) -> int: ids = [cid for cid, c in self._clips.items() if c.plan_id == plan_id] for cid in ids: del self._clips[cid] return len(ids) def count( self, *, plan_id: Optional[str] = None, status: Optional[EditPlanClipStatus] = None, ) -> int: items = list(self._clips.values()) if plan_id: items = [c for c in items if c.plan_id == plan_id] if status: items = [c for c in items if c.status == status] return len(items) class StubGenerationTaskRepository: """内存中的 GenerationTask 仓储 stub""" def __init__(self) -> None: self._tasks: dict[str, Any] = {} def get(self, task_id: str) -> Optional[Any]: return self._tasks.get(task_id) def create(self, task: Any) -> Any: self._tasks[task.id] = task return task def count_pending_by_user(self, user_id: str) -> int: return 0 def count_pending_total(self) -> int: return 0 def list_by_user_filtered( self, user_id: str, *, status: str | None = None, limit: int | None = None, offset: int = 0, ) -> list: """按用户+状态筛选任务列表(stub实现)。""" items = [t for t in self._tasks.values() if t.created_by_user_id == user_id] if status: items = [t for t in items if str(t.status) == status] # 按创建时间倒序 items.sort(key=lambda t: t.created_at or "", reverse=True) if offset: items = items[offset:] if limit is not None: items = items[:limit] return items def count_by_user_filtered( self, user_id: str, *, status: str | None = None, ) -> int: """按用户+状态筛选计数(stub实现)。""" items = [t for t in self._tasks.values() if t.created_by_user_id == user_id] if status: items = [t for t in items if str(t.status) == status] return len(items) def list_by_project_filtered( self, project_id: str, *, status: str | None = None, limit: int | None = None, offset: int = 0, ) -> list: """按项目+状态筛选任务列表(stub实现)。""" items = [t for t in self._tasks.values() if t.project_id == project_id] if status: items = [t for t in items if str(t.status) == status] # 按创建时间倒序 items.sort(key=lambda t: t.created_at or "", reverse=True) if offset: items = items[offset:] if limit is not None: items = items[:limit] return items def count_by_project_filtered( self, project_id: str, *, status: str | None = None, ) -> int: """按项目+状态筛选计数(stub实现)。""" items = [t for t in self._tasks.values() if t.project_id == project_id] if status: items = [t for t in items if str(t.status) == status] return len(items) # --------------------------------------------------------------------------- # Service factory # --------------------------------------------------------------------------- def _make_service(): """创建使用 stub 仓储的 EditPlanService""" from app.services.edit_plan_service import EditPlanService db = MagicMock() svc = EditPlanService(db) svc._plan_repo = StubEditPlanRepository() svc._clip_repo = StubEditPlanClipRepository() svc._generation_task_repo = StubGenerationTaskRepository() return svc # =========================================================================== # 计划 CRUD 测试 # =========================================================================== class TestEditPlanServiceCRUD: """计划 CRUD 测试""" def test_create_plan_success(self): svc = _make_service() plan = svc.create_plan(template_id="tpl-001", name="测试计划") assert plan.name == "测试计划" assert plan.template_id == "tpl-001" assert plan.status == EditPlanStatus.DRAFT assert plan.id def test_get_plan(self): svc = _make_service() created = svc.create_plan("tpl-001", "查询测试") fetched = svc.get_plan(created.id) assert fetched is not None assert fetched.id == created.id def test_get_plan_returns_none(self): svc = _make_service() assert svc.get_plan("nonexistent") is None def test_get_plan_or_raise(self): svc = _make_service() created = svc.create_plan("tpl-001", "查询测试") fetched = svc.get_plan_or_raise(created.id) assert fetched.id == created.id def test_get_plan_or_raise_not_found(self): svc = _make_service() with pytest.raises(ValueError, match="剪辑计划不存在"): svc.get_plan_or_raise("nonexistent") def test_list_plans(self): svc = _make_service() svc.create_plan("tpl-001", "计划1") svc.create_plan("tpl-001", "计划2") result = svc.list_plans() assert len(result) == 2 def test_list_plans_by_template(self): svc = _make_service() svc.create_plan("tpl-001", "计划1") svc.create_plan("tpl-002", "计划2") result = svc.list_plans(template_id="tpl-001") assert len(result) == 1 assert result[0].name == "计划1" def test_list_plans_by_status(self): svc = _make_service() p1 = svc.create_plan("tpl-001", "计划1") svc.create_plan("tpl-001", "计划2") svc.transition_status(p1.id, EditPlanStatus.EDITING) result = svc.list_plans(status=EditPlanStatus.EDITING) assert len(result) == 1 def test_count_plans(self): svc = _make_service() svc.create_plan("tpl-001", "计划1") svc.create_plan("tpl-001", "计划2") assert svc.count_plans() == 2 def test_update_plan_name(self): svc = _make_service() p = svc.create_plan("tpl-001", "原名") updated = svc.update_plan(p.id, name="新名") assert updated.name == "新名" def test_update_plan_not_found_raises(self): svc = _make_service() with pytest.raises(ValueError, match="剪辑计划不存在"): svc.update_plan("nonexistent", name="新名") def test_delete_plan(self): svc = _make_service() p = svc.create_plan("tpl-001", "要删除") assert svc.delete_plan(p.id) is True assert svc.get_plan(p.id) is None def test_delete_plan_not_found(self): svc = _make_service() assert svc.delete_plan("nonexistent") is False def test_delete_plan_also_deletes_clips(self): svc = _make_service() p = svc.create_plan("tpl-001", "带片段") svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) assert svc.count_clips(p.id) == 2 svc.delete_plan(p.id) # 片段应被一并删除 assert svc.count_clips(p.id) == 0 # =========================================================================== # 状态机流转测试 # =========================================================================== class TestStatusTransitions: """状态机流转测试""" def test_transition_draft_to_editing(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") result = svc.transition_status(p.id, EditPlanStatus.EDITING) assert result.status == EditPlanStatus.EDITING def test_transition_editing_to_rendering(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) result = svc.transition_status(p.id, EditPlanStatus.RENDERING) assert result.status == EditPlanStatus.RENDERING def test_transition_rendering_to_completed(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) result = svc.transition_status(p.id, EditPlanStatus.COMPLETED) assert result.status == EditPlanStatus.COMPLETED def test_transition_rendering_to_failed(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) result = svc.transition_status(p.id, EditPlanStatus.FAILED) assert result.status == EditPlanStatus.FAILED def test_transition_failed_to_draft(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.FAILED) result = svc.transition_status(p.id, EditPlanStatus.DRAFT) assert result.status == EditPlanStatus.DRAFT def test_transition_idempotent(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") result = svc.transition_status(p.id, EditPlanStatus.DRAFT) assert result.status == EditPlanStatus.DRAFT def test_transition_illegal_raises(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") # draft → completed 是非法的 with pytest.raises(ValueError): svc.transition_status(p.id, EditPlanStatus.COMPLETED) def test_transition_not_found_raises(self): svc = _make_service() with pytest.raises(ValueError, match="剪辑计划不存在"): svc.transition_status("nonexistent", EditPlanStatus.EDITING) # =========================================================================== # 片段管理测试 # =========================================================================== class TestClipManagement: """片段管理测试""" def test_create_clip(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "intro", 0) assert clip.plan_id == p.id assert clip.clip_type == "intro" assert clip.order == 0 assert clip.status == EditPlanClipStatus.PENDING def test_create_clip_plan_not_found_raises(self): svc = _make_service() with pytest.raises(ValueError, match="剪辑计划不存在"): svc.create_clip("nonexistent", "intro", 0) def test_list_clips(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) svc.create_clip(p.id, "outro", 2) result = svc.list_clips(p.id) assert len(result) == 3 def test_list_clips_by_status(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") c1 = svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) # assign_asset 只设置 asset_id,需要额外 mark_ready 才变 ready svc.assign_asset(c1.id, "asset-001") # 手动 mark_ready clip_obj = svc._clip_repo.get(c1.id) clip_obj.mark_ready() svc._clip_repo.update(clip_obj) result = svc.list_clips(p.id, status=EditPlanClipStatus.READY) assert len(result) == 1 def test_count_clips(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) assert svc.count_clips(p.id) == 2 def test_get_clip(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "intro", 0) fetched = svc.get_clip(clip.id) assert fetched is not None assert fetched.id == clip.id def test_get_clip_or_raise_not_found(self): svc = _make_service() with pytest.raises(ValueError, match="片段不存在"): svc.get_clip_or_raise("nonexistent") def test_update_clip(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "intro", 0, duration=3.0) updated = svc.update_clip(clip.id, duration=5.0) assert updated.duration == 5.0 def test_update_clip_not_found_raises(self): svc = _make_service() with pytest.raises(ValueError, match="片段不存在"): svc.update_clip("nonexistent", duration=5.0) def test_assign_asset(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "intro", 0) result = svc.assign_asset(clip.id, "asset-001") assert result.asset_id == "asset-001" # assign_asset 只设置 asset_id,不改变状态(状态需 mark_ready 流转) assert result.status == EditPlanClipStatus.PENDING def test_assign_asset_empty_raises(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "intro", 0) with pytest.raises(ValueError): svc.assign_asset(clip.id, "") def test_delete_clip(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "intro", 0) assert svc.delete_clip(clip.id) is True assert svc.get_clip(clip.id) is None def test_delete_all_clips(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) count = svc.delete_all_clips(p.id) assert count == 2 assert svc.count_clips(p.id) == 0 # =========================================================================== # 渲染生成流程测试 # =========================================================================== class TestGenerationWorkflow: """渲染生成流程测试""" def test_can_generate_editing_with_clips(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.create_clip(p.id, "intro", 0) can, reason = svc.can_generate(p.id) assert can is True assert reason == "" def test_can_generate_draft_fails(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) can, reason = svc.can_generate(p.id) assert can is False assert "编辑" in reason or "模板" in reason def test_can_generate_no_clips_fails(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) can, reason = svc.can_generate(p.id) assert can is False assert "请先添加片段后再生成视频" in reason def test_mark_clips_ready(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) count = svc.mark_clips_ready(p.id) assert count == 2 # 验证所有片段都是 ready 状态 clips = svc.list_clips(p.id) for c in clips: assert c.status == EditPlanClipStatus.READY def test_get_plan_with_clips(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) svc.create_clip(p.id, "main", 1) result = svc.get_plan_with_clips(p.id) assert result["plan"].id == p.id assert len(result["clips"]) == 2 def test_get_plan_with_clips_not_found(self): svc = _make_service() with pytest.raises(ValueError, match="剪辑计划不存在"): svc.get_plan_with_clips("nonexistent") def test_get_generation_status(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) result = svc.get_generation_status(p.id) assert result["plan"].id == p.id assert len(result["clips"]) == 1 assert result["generation_task_id"] is None def test_update_plan_config(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"}) updated = svc.update_plan_config(p.id, {"key2": "val2"}) assert updated.config["key1"] == "val1" assert updated.config["key2"] == "val2" def test_update_plan_config_overwrites(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"}) updated = svc.update_plan_config(p.id, {"key1": "new_val"}) assert updated.config["key1"] == "new_val" # ── 重新编辑 & 再生成 ──────────────────────────────────────────────────────── class TestResumeEditingAndRegenerate: """完成/失败后重新编辑 → 再生成的状态流转测试""" def test_update_plan_from_completed_returns_to_editing(self): """更新计划配置:completed → 自动切回 editing""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.COMPLETED) updated = svc.update_plan(p.id, name="新名字") assert updated.status == EditPlanStatus.EDITING assert updated.name == "新名字" def test_update_plan_config_from_completed_returns_to_editing(self): """update_plan_config: completed → 自动切回 editing""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.COMPLETED) updated = svc.update_plan_config(p.id, {"foo": "bar"}) assert updated.status == EditPlanStatus.EDITING def test_create_clip_from_completed_returns_to_editing(self): """创建片段:completed → 自动切回 editing""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.COMPLETED) svc.create_clip(p.id, "main", 0) plan_after = svc.get_plan(p.id) assert plan_after.status == EditPlanStatus.EDITING def test_assign_asset_from_failed_returns_to_editing(self): """分配素材:failed → 自动切回 editing""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") clip = svc.create_clip(p.id, "main", 0) svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.FAILED) svc.assign_asset(clip.id, "asset-001") plan_after = svc.get_plan(p.id) assert plan_after.status == EditPlanStatus.EDITING def test_completed_can_regenerate_after_edit(self): """完成后编辑 → can_generate 返回 True,可再生成""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "main", 0) svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.COMPLETED) # 完成后不能直接生成 can, reason = svc.can_generate(p.id) assert not can assert "编辑" in reason # 编辑后自动切回 editing,可以生成 svc.update_plan_config(p.id, {"edited": True}) can, reason = svc.can_generate(p.id) assert can, f"期望可生成,实际: {reason}" def test_transition_completed_to_editing_via_service(self): """通过 transition_status 从 completed 切到 editing""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.COMPLETED) result = svc.transition_status(p.id, EditPlanStatus.EDITING) assert result.status == EditPlanStatus.EDITING def test_resume_editing_from_draft_raises(self): """从 draft 直接 resume_editing 应该报错""" p = EditPlan.create("tpl-001", "测试") with pytest.raises(ValueError): p.resume_editing() # =========================================================================== # 片段分割与合并测试 # =========================================================================== class TestClipSplit: """片段分割测试""" def test_split_basic(self): """基础分割:10秒片段在第3秒处分割""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0, text_content="测试文案") result = svc.split_clip(clip.id, 3.0) assert result["left_clip"].duration == 3.0 assert result["left_clip"].order == 0 assert result["right_clip"].duration == 7.0 assert result["right_clip"].order == 1 assert result["right_clip"].clip_type == "main" assert result["right_clip"].text_content == "测试文案" # 总片段数 = 2 assert svc.count_clips(p.id) == 2 def test_split_preserves_clip_properties(self): """分割后属性继承正确""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip( p.id, "intro", 0, duration=10.0, transition_effect="fade", playback_speed=1.5, config={"filter": "vivid"}, ) result = svc.split_clip(clip.id, 5.0) right = result["right_clip"] assert right.clip_type == "intro" assert right.transition_effect == "fade" assert right.playback_speed == 1.5 assert right.config.get("filter") == "vivid" def test_split_shifts_following_clips(self): """分割后,后面的片段 order 自动 +1""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) clip1 = svc.create_clip(p.id, "main", 1, duration=5.0) clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) svc.split_clip(clip1.id, 2.0) # clip0: order 0 # clip1(left): order 1 # new right: order 2 # clip2: order 3 clips = svc.list_clips(p.id) order_map = {c.id: c.order for c in clips} assert order_map[clip0.id] == 0 assert order_map[clip1.id] == 1 assert order_map[clip2.id] == 3 assert len(clips) == 4 def test_split_at_boundary_raises(self): """分割点为0或等于时长时,报错""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) with pytest.raises(ValueError, match="分割时间"): svc.split_clip(clip.id, 0.0) with pytest.raises(ValueError, match="分割时间"): svc.split_clip(clip.id, 10.0) def test_split_negative_time_raises(self): """负数分割点报错""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) with pytest.raises(ValueError, match="分割时间"): svc.split_clip(clip.id, -1.0) def test_split_nonexistent_clip_raises(self): """不存在的片段报错""" svc = _make_service() with pytest.raises(ValueError, match="片段不存在"): svc.split_clip("nonexistent", 5.0) def test_split_with_asset_adds_trim_info(self): """有素材的片段分割后,添加trim_start/trim_end""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001") result = svc.split_clip(clip.id, 3.0) left = result["left_clip"] right = result["right_clip"] # 左半部分有 trim_end assert left.config.get("trim_end") == 7.0 # 右半部分有 trim_start assert right.config.get("trim_start") == 3.0 # 右半部分也关联同一个素材 assert right.asset_id == "asset-001" class TestSubtitleManagement: """字幕管理测试""" def test_add_subtitle_basic(self): """基础:添加一条字幕""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) subtitle = svc.add_subtitle(clip.id, start=1.0, end=3.0, text="大家好") assert subtitle["text"] == "大家好" assert subtitle["start"] == 1.0 assert subtitle["end"] == 3.0 assert "id" in subtitle assert len(subtitle["id"]) > 0 def test_add_subtitle_with_style(self): """添加带样式的字幕""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) style = {"font_size": 24, "color": "#ffffff", "position": "bottom"} subtitle = svc.add_subtitle(clip.id, start=0.0, end=2.0, text="测试", style=style) assert subtitle["style"]["font_size"] == 24 assert subtitle["style"]["color"] == "#ffffff" def test_list_subtitles_sorted_by_time(self): """字幕列表按时间排序""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) svc.add_subtitle(clip.id, start=5.0, end=6.0, text="第二") svc.add_subtitle(clip.id, start=1.0, end=2.0, text="第一") svc.add_subtitle(clip.id, start=8.0, end=9.0, text="第三") subtitles = svc.list_subtitles(clip.id) assert len(subtitles) == 3 assert subtitles[0]["text"] == "第一" assert subtitles[1]["text"] == "第二" assert subtitles[2]["text"] == "第三" def test_add_subtitle_invalid_time_raises(self): """非法时间报错""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) # 开始时间为负 with pytest.raises(ValueError, match="时间非法"): svc.add_subtitle(clip.id, start=-1.0, end=2.0, text="test") # 结束时间 <= 开始时间 with pytest.raises(ValueError, match="时间非法"): svc.add_subtitle(clip.id, start=5.0, end=3.0, text="test") # 超过片段时长 with pytest.raises(ValueError, match="不能超过片段时长"): svc.add_subtitle(clip.id, start=8.0, end=15.0, text="test") def test_add_subtitle_empty_text_raises(self): """空文本报错""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) with pytest.raises(ValueError, match="不能为空"): svc.add_subtitle(clip.id, start=1.0, end=2.0, text=" ") def test_get_subtitle(self): """获取单条字幕""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试") found = svc.get_subtitle(clip.id, sub["id"]) assert found is not None assert found["text"] == "测试" # 不存在的返回 None assert svc.get_subtitle(clip.id, "nonexistent") is None def test_update_subtitle_text(self): """更新字幕文本""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原文") updated = svc.update_subtitle(clip.id, sub["id"], text="修改后") assert updated["text"] == "修改后" assert updated["start"] == 1.0 # 时间不变 def test_update_subtitle_time(self): """更新字幕时间""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试") updated = svc.update_subtitle(clip.id, sub["id"], start=3.0, end=5.0) assert updated["start"] == 3.0 assert updated["end"] == 5.0 def test_update_subtitle_not_found_raises(self): """更新不存在的字幕报错""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) with pytest.raises(ValueError, match="字幕不存在"): svc.update_subtitle(clip.id, "fake-id", text="test") def test_delete_subtitle(self): """删除字幕""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="要删的") assert svc.count_clips(p.id) == 1 # 片段还在 deleted = svc.delete_subtitle(clip.id, sub["id"]) assert deleted is True subtitles = svc.list_subtitles(clip.id) assert len(subtitles) == 0 def test_delete_subtitle_not_found(self): """删除不存在的字幕返回 False""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) deleted = svc.delete_subtitle(clip.id, "nonexistent") assert deleted is False def test_batch_update_subtitles(self): """批量更新字幕(全量替换)""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=20.0) # 先加一条 svc.add_subtitle(clip.id, start=1.0, end=2.0, text="旧字幕") # 全量替换为 3 条 new_subs = [ {"start": 0.0, "end": 3.0, "text": "第一条"}, {"start": 4.0, "end": 7.0, "text": "第二条"}, {"start": 8.0, "end": 12.0, "text": "第三条"}, ] result = svc.batch_update_subtitles(clip.id, new_subs) assert len(result) == 3 assert result[0]["text"] == "第一条" # 都有 id assert all("id" in s for s in result) # 旧字幕没了 subtitles = svc.list_subtitles(clip.id) assert len(subtitles) == 3 def test_batch_update_preserves_existing_ids(self): """批量更新时已有 id 的字幕保留原 id""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原字幕") original_id = sub["id"] # 带 id 批量更新,修改文本 updated_list = svc.batch_update_subtitles( clip.id, [{"id": original_id, "start": 1.0, "end": 3.0, "text": "修改了"}], ) assert len(updated_list) == 1 assert updated_list[0]["id"] == original_id assert updated_list[0]["text"] == "修改了" def test_batch_update_skips_empty_text(self): """批量更新时空文本自动跳过""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) subs = [ {"start": 0.0, "end": 1.0, "text": "有效"}, {"start": 2.0, "end": 3.0, "text": " "}, # 空白,跳过 {"start": 4.0, "end": 5.0, "text": "也有效"}, ] result = svc.batch_update_subtitles(clip.id, subs) assert len(result) == 2 def test_empty_clip_returns_empty_list(self): """没有字幕的片段返回空列表""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=10.0) subtitles = svc.list_subtitles(clip.id) assert subtitles == [] class TestClipMerge: """片段合并测试""" def test_merge_two_clips(self): """基础合并:两个5秒片段合并为10秒""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段") clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段") merged = svc.merge_clips([clip1.id, clip2.id]) assert merged.duration == 10.0 assert merged.order == 0 assert merged.clip_type == "main" assert "第一段" in merged.text_content assert "第二段" in merged.text_content # 总片段数 = 1 assert svc.count_clips(p.id) == 1 def test_merge_shifts_following_clips(self): """合并后,后面的片段 order 前移""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) clip1 = svc.create_clip(p.id, "main", 1, duration=5.0) clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) clip3 = svc.create_clip(p.id, "main", 3, duration=5.0) svc.merge_clips([clip1.id, clip2.id]) clips = svc.list_clips(p.id) order_map = {c.id: c.order for c in clips} assert order_map[clip0.id] == 0 assert order_map[clip3.id] == 2 # 原来order=3,前移1位=2 assert len(clips) == 3 def test_merge_three_clips(self): """合并3个片段""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clips = [] for i in range(3): c = svc.create_clip(p.id, "main", i, duration=3.0) clips.append(c) merged = svc.merge_clips([c.id for c in clips]) assert merged.duration == 9.0 assert svc.count_clips(p.id) == 1 def test_merge_different_types_raises(self): """不同类型片段不能合并""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0) clip2 = svc.create_clip(p.id, "main", 1, duration=5.0) with pytest.raises(ValueError, match="相同类型"): svc.merge_clips([clip1.id, clip2.id]) def test_merge_non_contiguous_raises(self): """不连续的片段不能合并""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip0 = svc.create_clip(p.id, "main", 0, duration=5.0) svc.create_clip(p.id, "main", 1, duration=5.0) clip2 = svc.create_clip(p.id, "main", 2, duration=5.0) with pytest.raises(ValueError, match="不连续"): svc.merge_clips([clip0.id, clip2.id]) def test_merge_single_clip_raises(self): """单个片段不能合并""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip = svc.create_clip(p.id, "main", 0, duration=5.0) with pytest.raises(ValueError, match="至少需要 2 个"): svc.merge_clips([clip.id]) def test_merge_different_plans_raises(self): """不同计划的片段不能合并""" svc = _make_service() p1 = svc.create_plan("tpl-001", "计划1") p2 = svc.create_plan("tpl-001", "计划2") svc.transition_status(p1.id, EditPlanStatus.EDITING) svc.transition_status(p2.id, EditPlanStatus.EDITING) clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0) clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0) with pytest.raises(ValueError, match="同一计划"): svc.merge_clips([clip1.id, clip2.id]) def test_merge_clears_trim_fields(self): """合并后清理trim字段""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"}) clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0}) merged = svc.merge_clips([clip1.id, clip2.id]) assert "trim_start" not in merged.config assert "trim_end" not in merged.config # 非 trim 字段保留(后面的覆盖前面的) assert merged.config.get("filter") == "vivid" def test_split_then_merge_recovers(self): """分割后再合并,时长基本恢复(浮点精度内)""" svc = _make_service() p = svc.create_plan("tpl-001", "测试计划") svc.transition_status(p.id, EditPlanStatus.EDITING) original = svc.create_clip(p.id, "main", 0, duration=10.0) result = svc.split_clip(original.id, 3.5) left = result["left_clip"] right = result["right_clip"] merged = svc.merge_clips([left.id, right.id]) assert abs(merged.duration - 10.0) < 0.001 assert svc.count_clips(p.id) == 1