4148ae29a4
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m35s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m36s
CI/CD Pipeline / Unit Tests (push) Successful in 3m27s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m45s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 9m2s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m31s
754 lines
27 KiB
Python
Executable File
754 lines
27 KiB
Python
Executable File
"""
|
||
EditTemplateService 单元测试
|
||
|
||
覆盖(30+ 测试用例):
|
||
- 模板 CRUD:创建、查询、更新、软删除
|
||
- 名称去重校验
|
||
- 片段配置 CRUD
|
||
- 片段配置重排序
|
||
- 复合查询 get_template_with_configs
|
||
- 异常处理:不存在、参数校验
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import 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_template import EditTemplate, EditTemplateStatus
|
||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stub Repositories
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class StubEditTemplateRepository:
|
||
"""内存中的 EditTemplate 仓储 stub"""
|
||
|
||
def __init__(self) -> None:
|
||
self._templates: dict[str, EditTemplate] = {}
|
||
self._counter = 0
|
||
|
||
def _next_id(self) -> str:
|
||
self._counter += 1
|
||
return f"tpl-{self._counter:03d}"
|
||
|
||
def list_all(
|
||
self,
|
||
*,
|
||
template_type: Optional[str] = None,
|
||
status: Optional[EditTemplateStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> List[EditTemplate]:
|
||
items = list(self._templates.values())
|
||
if template_type:
|
||
items = [t for t in items if t.template_type == template_type]
|
||
if status:
|
||
items = [t for t in items if t.status == status]
|
||
return items[skip : skip + limit]
|
||
|
||
def list_active(
|
||
self,
|
||
*,
|
||
template_type: Optional[str] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> List[EditTemplate]:
|
||
items = [t for t in self._templates.values() if t.status == EditTemplateStatus.ACTIVE]
|
||
if template_type:
|
||
items = [t for t in items if t.template_type == template_type]
|
||
return items[skip : skip + limit]
|
||
|
||
def get(self, template_id: str) -> Optional[EditTemplate]:
|
||
return self._templates.get(template_id)
|
||
|
||
def create(self, template: EditTemplate) -> EditTemplate:
|
||
if not template.id:
|
||
template = EditTemplate(
|
||
id=self._next_id(),
|
||
name=template.name,
|
||
description=template.description,
|
||
template_type=template.template_type,
|
||
config=template.config,
|
||
preview_url=template.preview_url,
|
||
sort_weight=template.sort_weight,
|
||
status=template.status,
|
||
created_at=datetime.now(timezone.utc),
|
||
updated_at=datetime.now(timezone.utc),
|
||
)
|
||
self._templates[template.id] = template
|
||
return template
|
||
|
||
def update(self, template: EditTemplate) -> EditTemplate:
|
||
self._templates[template.id] = template
|
||
return template
|
||
|
||
def delete(self, template_id: str) -> bool:
|
||
return self._templates.pop(template_id, None) is not None
|
||
|
||
def count(
|
||
self,
|
||
*,
|
||
template_type: Optional[str] = None,
|
||
status: Optional[EditTemplateStatus] = None,
|
||
) -> int:
|
||
items = list(self._templates.values())
|
||
if template_type:
|
||
items = [t for t in items if t.template_type == template_type]
|
||
if status:
|
||
items = [t for t in items if t.status == status]
|
||
return len(items)
|
||
|
||
|
||
class StubTemplateClipConfigRepository:
|
||
"""内存中的 TemplateClipConfig 仓储 stub"""
|
||
|
||
def __init__(self) -> None:
|
||
self._configs: dict[str, TemplateClipConfig] = {}
|
||
self._counter = 0
|
||
|
||
def _next_id(self) -> str:
|
||
self._counter += 1
|
||
return f"cfg-{self._counter:03d}"
|
||
|
||
def list_by_template(
|
||
self,
|
||
template_id: str,
|
||
*,
|
||
clip_type: Optional[ClipType] = None,
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
) -> List[TemplateClipConfig]:
|
||
items = [c for c in self._configs.values() if c.template_id == template_id]
|
||
if clip_type:
|
||
items = [c for c in items if c.clip_type == clip_type]
|
||
items.sort(key=lambda c: c.order)
|
||
return items[skip : skip + limit]
|
||
|
||
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||
return self._configs.get(config_id)
|
||
|
||
def create(self, config: TemplateClipConfig) -> TemplateClipConfig:
|
||
if not config.id:
|
||
config = TemplateClipConfig(
|
||
id=self._next_id(),
|
||
template_id=config.template_id,
|
||
clip_type=config.clip_type,
|
||
order=config.order,
|
||
min_duration=config.min_duration,
|
||
max_duration=config.max_duration,
|
||
text_template=config.text_template,
|
||
material_requirements=config.material_requirements,
|
||
transition_effect=config.transition_effect,
|
||
config=config.config,
|
||
created_at=datetime.now(timezone.utc),
|
||
updated_at=datetime.now(timezone.utc),
|
||
)
|
||
self._configs[config.id] = config
|
||
return config
|
||
|
||
def update(self, config: TemplateClipConfig) -> TemplateClipConfig:
|
||
self._configs[config.id] = config
|
||
return config
|
||
|
||
def delete(self, config_id: str) -> bool:
|
||
return self._configs.pop(config_id, None) is not None
|
||
|
||
def delete_by_template(self, template_id: str) -> int:
|
||
ids = [cid for cid, c in self._configs.items() if c.template_id == template_id]
|
||
for cid in ids:
|
||
del self._configs[cid]
|
||
return len(ids)
|
||
|
||
def count(self, template_id: str) -> int:
|
||
return len([c for c in self._configs.values() if c.template_id == template_id])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Service under test (inject stub repos)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_service():
|
||
"""创建使用 stub 仓储的 EditTemplateService"""
|
||
from app.services.edit_template_service import EditTemplateService
|
||
|
||
db = MagicMock()
|
||
svc = EditTemplateService(db)
|
||
svc._template_repo = StubEditTemplateRepository()
|
||
svc._clip_config_repo = StubTemplateClipConfigRepository()
|
||
return svc
|
||
|
||
|
||
# ===========================================================================
|
||
# 模板 CRUD 测试
|
||
# ===========================================================================
|
||
|
||
|
||
class TestEditTemplateServiceCRUD:
|
||
"""模板 CRUD 测试"""
|
||
|
||
def test_create_template_success(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="测试模板", description="描述")
|
||
assert t.name == "测试模板"
|
||
assert t.description == "描述"
|
||
assert t.status == EditTemplateStatus.ACTIVE
|
||
assert t.id
|
||
|
||
def test_create_template_strips_name(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name=" 测试 ")
|
||
assert t.name == "测试"
|
||
|
||
def test_create_template_empty_name_raises(self):
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||
svc.create_template(name=" ")
|
||
|
||
def test_create_template_duplicate_name_raises(self):
|
||
svc = _make_service()
|
||
svc.create_template(name="重复名称")
|
||
with pytest.raises(ValueError, match="模板名称已存在"):
|
||
svc.create_template(name="重复名称")
|
||
|
||
def test_create_template_inactive_name_can_reuse(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="可复用")
|
||
svc.deactivate_template(t.id)
|
||
# inactive 的名称可以复用
|
||
t2 = svc.create_template(name="可复用")
|
||
assert t2.name == "可复用"
|
||
assert t2.id != t.id
|
||
|
||
def test_get_template(self):
|
||
svc = _make_service()
|
||
created = svc.create_template(name="查询测试")
|
||
fetched = svc.get_template(created.id)
|
||
assert fetched is not None
|
||
assert fetched.id == created.id
|
||
|
||
def test_get_template_returns_none(self):
|
||
svc = _make_service()
|
||
assert svc.get_template("nonexistent") is None
|
||
|
||
def test_get_template_or_raise(self):
|
||
svc = _make_service()
|
||
created = svc.create_template(name="查询测试")
|
||
fetched = svc.get_template_or_raise(created.id)
|
||
assert fetched.id == created.id
|
||
|
||
def test_get_template_or_raise_not_found(self):
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="模板不存在"):
|
||
svc.get_template_or_raise("nonexistent")
|
||
|
||
def test_list_templates(self):
|
||
svc = _make_service()
|
||
svc.create_template(name="模板1")
|
||
svc.create_template(name="模板2")
|
||
result = svc.list_templates()
|
||
assert len(result) == 2
|
||
|
||
def test_list_templates_with_type_filter(self):
|
||
svc = _make_service()
|
||
svc.create_template(name="默认", template_type="default")
|
||
svc.create_template(name="Vlog", template_type="vlog")
|
||
result = svc.list_templates(template_type="vlog")
|
||
assert len(result) == 1
|
||
assert result[0].name == "Vlog"
|
||
|
||
def test_list_templates_active_only(self):
|
||
svc = _make_service()
|
||
svc.create_template(name="活跃")
|
||
t2 = svc.create_template(name="停用")
|
||
svc.deactivate_template(t2.id)
|
||
result = svc.list_templates(active_only=True)
|
||
assert len(result) == 1
|
||
assert result[0].name == "活跃"
|
||
|
||
def test_count_templates(self):
|
||
svc = _make_service()
|
||
svc.create_template(name="模板1")
|
||
svc.create_template(name="模板2")
|
||
assert svc.count_templates() == 2
|
||
|
||
def test_update_template_name(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="原名")
|
||
updated = svc.update_template(t.id, name="新名")
|
||
assert updated.name == "新名"
|
||
|
||
def test_update_template_duplicate_name_raises(self):
|
||
svc = _make_service()
|
||
svc.create_template(name="已存在")
|
||
t2 = svc.create_template(name="另一个")
|
||
with pytest.raises(ValueError, match="模板名称已存在"):
|
||
svc.update_template(t2.id, name="已存在")
|
||
|
||
def test_update_template_not_found_raises(self):
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="模板不存在"):
|
||
svc.update_template("nonexistent", name="新名")
|
||
|
||
def test_deactivate_template(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="要停用的")
|
||
result = svc.deactivate_template(t.id)
|
||
assert result.status == EditTemplateStatus.INACTIVE
|
||
|
||
def test_deactivate_template_not_found_raises(self):
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="模板不存在"):
|
||
svc.deactivate_template("nonexistent")
|
||
|
||
|
||
# ===========================================================================
|
||
# 片段配置管理测试
|
||
# ===========================================================================
|
||
|
||
|
||
class TestClipConfigManagement:
|
||
"""片段配置管理测试"""
|
||
|
||
def test_create_clip_config(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
cfg = svc.create_clip_config(
|
||
template_id=t.id,
|
||
clip_type=ClipType.INTRO,
|
||
order=0,
|
||
min_duration=1.0,
|
||
max_duration=5.0,
|
||
)
|
||
assert cfg.template_id == t.id
|
||
assert cfg.clip_type == ClipType.INTRO
|
||
assert cfg.order == 0
|
||
assert cfg.min_duration == 1.0
|
||
assert cfg.max_duration == 5.0
|
||
|
||
def test_create_clip_config_with_string_clip_type(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
cfg = svc.create_clip_config(
|
||
template_id=t.id,
|
||
clip_type="main",
|
||
order=1,
|
||
)
|
||
assert cfg.clip_type == ClipType.MAIN
|
||
|
||
def test_list_clip_configs(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
svc.create_clip_config(t.id, ClipType.MAIN, 1)
|
||
svc.create_clip_config(t.id, ClipType.OUTRO, 2)
|
||
result = svc.list_clip_configs(t.id)
|
||
assert len(result) == 3
|
||
# 按 order 排序
|
||
assert result[0].order == 0
|
||
assert result[1].order == 1
|
||
assert result[2].order == 2
|
||
|
||
def test_list_clip_configs_by_type(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
svc.create_clip_config(t.id, ClipType.MAIN, 1)
|
||
result = svc.list_clip_configs(t.id, clip_type=ClipType.MAIN)
|
||
assert len(result) == 1
|
||
assert result[0].clip_type == ClipType.MAIN
|
||
|
||
def test_get_clip_config(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
fetched = svc.get_clip_config(cfg.id)
|
||
assert fetched is not None
|
||
assert fetched.id == cfg.id
|
||
|
||
def test_get_clip_config_not_found(self):
|
||
svc = _make_service()
|
||
assert svc.get_clip_config("nonexistent") is None
|
||
|
||
def test_get_clip_config_or_raise(self):
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="片段配置不存在"):
|
||
svc.get_clip_config_or_raise("nonexistent")
|
||
|
||
def test_update_clip_config(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0, min_duration=1.0)
|
||
updated = svc.update_clip_config(cfg.id, min_duration=2.0, max_duration=10.0)
|
||
assert updated.min_duration == 2.0
|
||
assert updated.max_duration == 10.0
|
||
|
||
def test_delete_clip_config(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
assert svc.delete_clip_config(cfg.id) is True
|
||
assert svc.get_clip_config(cfg.id) is None
|
||
|
||
def test_delete_clip_config_not_found(self):
|
||
svc = _make_service()
|
||
assert svc.delete_clip_config("nonexistent") is False
|
||
|
||
def test_reorder_clip_configs(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
c1 = svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
c2 = svc.create_clip_config(t.id, ClipType.MAIN, 1)
|
||
c3 = svc.create_clip_config(t.id, ClipType.OUTRO, 2)
|
||
|
||
# 反转顺序
|
||
reordered = svc.reorder_clip_configs(t.id, [c3.id, c2.id, c1.id])
|
||
assert len(reordered) == 3
|
||
assert reordered[0].id == c3.id
|
||
assert reordered[0].order == 0
|
||
assert reordered[1].id == c2.id
|
||
assert reordered[1].order == 1
|
||
assert reordered[2].id == c1.id
|
||
assert reordered[2].order == 2
|
||
|
||
def test_reorder_clip_configs_mismatch_raises(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
c1 = svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
svc.create_clip_config(t.id, ClipType.MAIN, 1)
|
||
|
||
with pytest.raises(ValueError, match="配置 ID 列表与模板下的配置不匹配"):
|
||
svc.reorder_clip_configs(t.id, [c1.id]) # 缺少一个
|
||
|
||
|
||
# ===========================================================================
|
||
# 复合查询测试
|
||
# ===========================================================================
|
||
|
||
|
||
class TestCompositeQueries:
|
||
"""复合查询测试"""
|
||
|
||
def test_get_template_with_configs(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="模板")
|
||
svc.create_clip_config(t.id, ClipType.INTRO, 0)
|
||
svc.create_clip_config(t.id, ClipType.MAIN, 1)
|
||
|
||
result = svc.get_template_with_configs(t.id)
|
||
assert result["template"].id == t.id
|
||
assert len(result["clip_configs"]) == 2
|
||
|
||
def test_get_template_with_configs_not_found(self):
|
||
svc = _make_service()
|
||
with pytest.raises(ValueError, match="模板不存在"):
|
||
svc.get_template_with_configs("nonexistent")
|
||
|
||
def test_get_template_with_configs_empty(self):
|
||
svc = _make_service()
|
||
t = svc.create_template(name="空模板")
|
||
result = svc.get_template_with_configs(t.id)
|
||
assert len(result["clip_configs"]) == 0
|
||
|
||
|
||
# ===========================================================================
|
||
# Stub Repositories for EditPlan (save_as_template 测试用)
|
||
# ===========================================================================
|
||
|
||
|
||
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 get(self, plan_id: str) -> Optional[EditPlan]:
|
||
return self._plans.get(plan_id)
|
||
|
||
def create(self, plan: EditPlan) -> EditPlan:
|
||
if not plan.id:
|
||
plan.id = self._next_id()
|
||
self._plans[plan.id] = plan
|
||
return plan
|
||
|
||
|
||
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[str] = 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.value == status]
|
||
items.sort(key=lambda c: c.order)
|
||
return items[skip : skip + limit]
|
||
|
||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||
if not clip.id:
|
||
clip.id = self._next_id()
|
||
self._clips[clip.id] = clip
|
||
return clip
|
||
|
||
|
||
def _make_service_with_plan_stubs():
|
||
"""创建使用 stub 仓储的 EditTemplateService(含 plan 相关 stub)"""
|
||
from app.services.edit_template_service import EditTemplateService
|
||
|
||
db = MagicMock()
|
||
svc = EditTemplateService(db)
|
||
svc._template_repo = StubEditTemplateRepository()
|
||
svc._clip_config_repo = StubTemplateClipConfigRepository()
|
||
svc._plan_repo = StubEditPlanRepository()
|
||
svc._plan_clip_repo = StubEditPlanClipRepository()
|
||
return svc
|
||
|
||
|
||
def _make_test_plan_with_clips(svc, *, clip_count: int = 3, plan_config=None):
|
||
"""辅助方法:创建一个带片段的测试计划,返回 plan 对象"""
|
||
from packages.domain.edit_plan import EditPlan
|
||
from packages.domain.edit_plan_clip import EditPlanClip
|
||
|
||
plan = EditPlan.create(
|
||
template_id="tpl-source",
|
||
name="我的剪辑计划",
|
||
config=plan_config or {"editing_mode": "one_take", "theme": "minimal"},
|
||
project_id="proj-001",
|
||
created_by_user_id="user-001",
|
||
)
|
||
plan.id = "plan-test-001"
|
||
svc._plan_repo.create(plan)
|
||
|
||
for i in range(clip_count):
|
||
clip = EditPlanClip.create(
|
||
plan_id=plan.id,
|
||
clip_type=ClipType.MAIN.value,
|
||
order=i,
|
||
asset_id=f"asset-{i:03d}",
|
||
text_content=f"片段{i}的文案",
|
||
duration=10.0 + i * 5,
|
||
transition_effect="cut" if i == 0 else "fade",
|
||
playback_speed=1.0 if i == 0 else 1.5,
|
||
config={"filter": "vivid"} if i == 1 else {},
|
||
)
|
||
svc._plan_clip_repo.create(clip)
|
||
|
||
return plan
|
||
|
||
|
||
# ===========================================================================
|
||
# 保存为模板测试
|
||
# ===========================================================================
|
||
|
||
|
||
class TestSavePlanAsTemplate:
|
||
"""从剪辑计划保存为模板测试"""
|
||
|
||
def test_basic_save_as_template(self):
|
||
"""基础场景:将有3个片段的计划保存为模板"""
|
||
svc = _make_service_with_plan_stubs()
|
||
plan = _make_test_plan_with_clips(svc, clip_count=3)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="我的自定义模板")
|
||
|
||
assert result["template"].name == "我的自定义模板"
|
||
assert result["template"].template_type == "custom"
|
||
assert result["template"].editing_mode == "one_take"
|
||
assert result["template"].status == EditTemplateStatus.ACTIVE
|
||
assert len(result["clip_configs"]) == 3
|
||
|
||
def test_clip_configs_correctly_converted(self):
|
||
"""片段正确转换为模板片段配置"""
|
||
svc = _make_service_with_plan_stubs()
|
||
plan = _make_test_plan_with_clips(svc, clip_count=2)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="转换测试模板")
|
||
configs = result["clip_configs"]
|
||
configs.sort(key=lambda c: c.order)
|
||
|
||
# 第0个片段
|
||
assert configs[0].clip_type == ClipType.MAIN
|
||
assert configs[0].order == 0
|
||
assert configs[0].min_duration == 10.0
|
||
assert configs[0].max_duration == 10.0
|
||
assert configs[0].text_template == "片段0的文案"
|
||
assert configs[0].transition_effect.value == "cut"
|
||
# playback_speed=1.0 不存
|
||
assert "playback_speed" not in configs[0].config
|
||
|
||
# 第1个片段
|
||
assert configs[1].order == 1
|
||
assert configs[1].min_duration == 15.0
|
||
assert configs[1].max_duration == 15.0
|
||
assert configs[1].transition_effect.value == "fade"
|
||
# playback_speed=1.5 存入config
|
||
assert configs[1].config.get("playback_speed") == 1.5
|
||
# config 中的 filter 保留
|
||
assert configs[1].config.get("filter") == "vivid"
|
||
|
||
def test_no_asset_id_in_template(self):
|
||
"""模板不保留具体素材ID"""
|
||
svc = _make_service_with_plan_stubs()
|
||
plan = _make_test_plan_with_clips(svc, clip_count=2)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="素材剥离测试")
|
||
|
||
for cfg in result["clip_configs"]:
|
||
# 模板片段配置没有 asset_id 字段
|
||
assert not hasattr(cfg, "asset_id") or not getattr(cfg, "asset_id", "")
|
||
# config 中也不应有素材相关字段
|
||
assert "asset_info" not in cfg.config
|
||
assert "source_asset_id" not in cfg.config
|
||
|
||
def test_template_config_stripped_of_runtime_fields(self):
|
||
"""模板config剥离运行时字段"""
|
||
svc = _make_service_with_plan_stubs()
|
||
plan_config = {
|
||
"editing_mode": "one_take",
|
||
"theme": "cinematic",
|
||
"asset_ids": ["a1", "a2"],
|
||
"source_edit_plan_id": "old-plan",
|
||
"generation_task_id": "task-123",
|
||
}
|
||
plan = _make_test_plan_with_clips(svc, clip_count=1, plan_config=plan_config)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="配置剥离测试")
|
||
|
||
tpl_config = result["template"].config
|
||
assert tpl_config.get("theme") == "cinematic"
|
||
assert "asset_ids" not in tpl_config
|
||
assert "source_edit_plan_id" not in tpl_config
|
||
assert "generation_task_id" not in tpl_config
|
||
|
||
def test_plan_not_found_raises_error(self):
|
||
"""计划不存在时报错"""
|
||
svc = _make_service_with_plan_stubs()
|
||
|
||
with pytest.raises(ValueError, match="剪辑计划不存在"):
|
||
svc.save_plan_as_template("nonexistent-plan", name="不存在的计划")
|
||
|
||
def test_empty_name_raises_error(self):
|
||
"""模板名称为空时报错"""
|
||
svc = _make_service_with_plan_stubs()
|
||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||
|
||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||
svc.save_plan_as_template(plan.id, name=" ")
|
||
|
||
def test_duplicate_name_raises_error(self):
|
||
"""模板名称重复时报错"""
|
||
svc = _make_service_with_plan_stubs()
|
||
svc.create_template(name="重名模板")
|
||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||
|
||
with pytest.raises(ValueError, match="模板名称已存在"):
|
||
svc.save_plan_as_template(plan.id, name="重名模板")
|
||
|
||
def test_save_zero_clip_plan(self):
|
||
"""零片段计划也能保存为模板"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
plan = EditPlan.create(
|
||
template_id="tpl-source",
|
||
name="空计划",
|
||
config={"editing_mode": "one_take"},
|
||
)
|
||
plan.id = "plan-empty"
|
||
svc._plan_repo.create(plan)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="空模板")
|
||
|
||
assert result["template"].name == "空模板"
|
||
assert len(result["clip_configs"]) == 0
|
||
|
||
def test_custom_description_and_type(self):
|
||
"""自定义描述和模板类型"""
|
||
svc = _make_service_with_plan_stubs()
|
||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||
|
||
result = svc.save_plan_as_template(
|
||
plan.id,
|
||
name="自定义模板",
|
||
description="这是一个测试模板",
|
||
template_type="vlog",
|
||
)
|
||
|
||
assert result["template"].description == "这是一个测试模板"
|
||
assert result["template"].template_type == "vlog"
|
||
|
||
def test_unknown_transition_effect_falls_back_to_cut(self):
|
||
"""未知转场效果回退到cut"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
from packages.domain.edit_plan_clip import EditPlanClip
|
||
|
||
plan = EditPlan.create(template_id="tpl-src", name="转场测试计划")
|
||
plan.id = "plan-transition-test"
|
||
svc._plan_repo.create(plan)
|
||
|
||
clip = EditPlanClip.create(
|
||
plan_id=plan.id,
|
||
clip_type="main",
|
||
order=0,
|
||
duration=10.0,
|
||
transition_effect="weird_effect_that_does_not_exist",
|
||
)
|
||
svc._plan_clip_repo.create(clip)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="转场兼容模板")
|
||
assert result["clip_configs"][0].transition_effect.value == "cut"
|
||
|
||
def test_unknown_clip_type_falls_back_to_main(self):
|
||
"""未知片段类型回退到main"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
from packages.domain.edit_plan_clip import EditPlanClip
|
||
|
||
plan = EditPlan.create(template_id="tpl-src", name="类型测试计划")
|
||
plan.id = "plan-type-test"
|
||
svc._plan_repo.create(plan)
|
||
|
||
clip = EditPlanClip.create(
|
||
plan_id=plan.id,
|
||
clip_type="unknown_clip_type",
|
||
order=0,
|
||
duration=10.0,
|
||
)
|
||
svc._plan_clip_repo.create(clip)
|
||
|
||
result = svc.save_plan_as_template(plan.id, name="类型兼容模板")
|
||
assert result["clip_configs"][0].clip_type == ClipType.MAIN
|