Files
xiaoxia-saas/tests/unit/test_edit_plan_service.py
xiaoxia aff8f6dae8
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
fix: 生成时报错「没有可渲染的就绪片段」— mark_clips_ready 仅标记有素材的片段 (#1270) (#1276)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-07 20:46:00 +08:00

908 lines
33 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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_update_plan_not_found_raises(self):
svc = _make_service()
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.update_plan("nonexistent", name="新名")
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)
clip = svc.create_clip(p.id, "intro", 0)
svc.assign_asset(clip.id, "asset-001")
can, reason = svc.can_generate(p.id)
assert can is True
assert reason == ""
def test_can_generate_no_assets_fails(self):
"""片段存在但没有分配素材时,can_generate 应返回 False"""
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 False
assert "没有可渲染" in reason or "素材" in 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):
"""只有分配了素材的 pending 片段才会被标记为 ready"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip1 = svc.create_clip(p.id, "intro", 0)
clip2 = svc.create_clip(p.id, "main", 1)
# 只给 clip1 分配素材
svc.assign_asset(clip1.id, "asset-001")
count = svc.mark_clips_ready(p.id)
assert count == 1 # 只有 clip1 被标记
# 验证 clip1 是 readyclip2 仍是 pending
clips = svc.list_clips(p.id)
clips_by_order = {c.order: c for c in clips}
assert clips_by_order[0].status == EditPlanClipStatus.READY
assert clips_by_order[1].status == EditPlanClipStatus.PENDING
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", "测试")
clip = svc.create_clip(p.id, "main", 0)
svc.assign_asset(clip.id, "asset-001")
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 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