ed705187ca
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 49s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
feat: 字幕管理 - 增删改查 + 批量导入(基于片段config) (#394)
1210 lines
44 KiB
Python
Executable File
1210 lines
44 KiB
Python
Executable File
"""
|
|
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
|