981d965d89
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m42s
CI/CD Pipeline / Unit Tests (push) Successful in 6m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m13s
CI/CD Pipeline / Integration Tests (push) Successful in 2m14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 17m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1023 lines
36 KiB
Python
Executable File
1023 lines
36 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, *, commit: bool = True) -> 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
|
||
|
||
def list_by_template(
|
||
self,
|
||
template_id: str,
|
||
*,
|
||
status: Optional[str] = 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.value == status]
|
||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||
return items[skip : skip + limit]
|
||
|
||
def update(self, plan: EditPlan) -> EditPlan:
|
||
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
|
||
|
||
|
||
class TestTemplateDraft:
|
||
"""模板草稿相关功能测试"""
|
||
|
||
def test_get_template_draft_not_found(self):
|
||
"""没有草稿时返回None"""
|
||
svc = _make_service_with_plan_stubs()
|
||
# 先创建一个模板
|
||
tpl = svc.create_template(name="测试模板", editing_mode="one_take")
|
||
result = svc.get_template_draft(tpl.id)
|
||
assert result is None
|
||
|
||
def test_get_template_draft_found(self):
|
||
"""能正确找到标记了is_template_draft的草稿"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
tpl = svc.create_template(name="测试模板", editing_mode="one_take")
|
||
|
||
# 普通计划(不是草稿)
|
||
normal_plan = EditPlan.create(template_id=tpl.id, name="普通计划", config={"key": "value"})
|
||
normal_plan.id = "plan-normal"
|
||
svc._plan_repo.create(normal_plan)
|
||
|
||
# 草稿计划
|
||
draft_plan = EditPlan.create(
|
||
template_id=tpl.id,
|
||
name="草稿计划",
|
||
config={"is_template_draft": True, "other": "data"},
|
||
)
|
||
draft_plan.id = "plan-draft"
|
||
svc._plan_repo.create(draft_plan)
|
||
|
||
result = svc.get_template_draft(tpl.id)
|
||
assert result is not None
|
||
assert result.id == "plan-draft"
|
||
assert result.config["is_template_draft"] is True
|
||
|
||
def test_create_template_draft_success(self, monkeypatch):
|
||
"""创建草稿成功,标记is_template_draft=True"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
tpl = svc.create_template(name="草稿测试模板", editing_mode="one_take")
|
||
|
||
# mock PlanGeneratorService.generate_from_template
|
||
called_with = {}
|
||
|
||
def fake_generate(self, template, clip_configs, asset_ids, **kwargs):
|
||
called_with["template"] = template
|
||
called_with["clip_configs"] = clip_configs
|
||
called_with["asset_ids"] = asset_ids
|
||
called_with["kwargs"] = kwargs
|
||
plan = EditPlan.create(
|
||
template_id=template.id,
|
||
name=kwargs.get("name", "测试草稿"),
|
||
config={"editing_mode": "one_take"},
|
||
)
|
||
plan.id = "plan-new-draft"
|
||
svc._plan_repo.create(plan)
|
||
return {"plan": plan, "clips": []}
|
||
|
||
from app.services import plan_generator_service
|
||
|
||
monkeypatch.setattr(
|
||
plan_generator_service.PlanGeneratorService,
|
||
"generate_from_template",
|
||
fake_generate,
|
||
)
|
||
|
||
draft = svc.create_template_draft(tpl.id, "user-001")
|
||
|
||
assert draft is not None
|
||
assert draft.id == "plan-new-draft"
|
||
assert draft.config.get("is_template_draft") is True
|
||
assert called_with["asset_ids"] == []
|
||
assert called_with["kwargs"]["created_by_user_id"] == "user-001"
|
||
|
||
def test_create_template_draft_duplicate_raises(self):
|
||
"""草稿已存在时抛ValueError"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
tpl = svc.create_template(name="重复草稿模板", editing_mode="one_take")
|
||
|
||
# 预先创建一个草稿
|
||
draft = EditPlan.create(
|
||
template_id=tpl.id,
|
||
name="已存在草稿",
|
||
config={"is_template_draft": True},
|
||
)
|
||
draft.id = "plan-existing"
|
||
svc._plan_repo.create(draft)
|
||
|
||
import pytest
|
||
|
||
with pytest.raises(ValueError, match="草稿已存在"):
|
||
svc.create_template_draft(tpl.id, "user-001")
|
||
|
||
def test_get_or_create_draft_creates_when_missing(self, monkeypatch):
|
||
"""草稿不存在时自动创建"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
tpl = svc.create_template(name="自动创建模板", editing_mode="one_take")
|
||
|
||
def fake_generate(self, template, clip_configs, asset_ids, **kwargs):
|
||
plan = EditPlan.create(
|
||
template_id=template.id,
|
||
name="自动创建草稿",
|
||
config={"editing_mode": "one_take"},
|
||
)
|
||
plan.id = "plan-auto"
|
||
svc._plan_repo.create(plan)
|
||
return {"plan": plan, "clips": []}
|
||
|
||
from app.services import plan_generator_service
|
||
|
||
monkeypatch.setattr(
|
||
plan_generator_service.PlanGeneratorService,
|
||
"generate_from_template",
|
||
fake_generate,
|
||
)
|
||
|
||
# 第一次调用:创建
|
||
draft1 = svc.get_or_create_draft(tpl.id, "user-001")
|
||
assert draft1 is not None
|
||
assert draft1.config.get("is_template_draft") is True
|
||
|
||
# 第二次调用:返回已存在的
|
||
draft2 = svc.get_or_create_draft(tpl.id, "user-001")
|
||
assert draft2.id == draft1.id
|
||
|
||
def test_publish_template_from_draft_success(self):
|
||
"""草稿发布成功,同步config和clips到模板"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
from packages.domain.edit_plan_clip import EditPlanClip
|
||
|
||
# 创建模板和初始片段配置
|
||
tpl = svc.create_template(
|
||
name="发布测试模板",
|
||
editing_mode="one_take",
|
||
config={"original": "value"},
|
||
)
|
||
svc.create_clip_config(
|
||
tpl.id,
|
||
clip_type="main",
|
||
order=0,
|
||
min_duration=5.0,
|
||
max_duration=5.0,
|
||
)
|
||
|
||
# 创建草稿
|
||
draft = EditPlan.create(
|
||
template_id=tpl.id,
|
||
name="发布草稿",
|
||
config={
|
||
"is_template_draft": True,
|
||
"editing_mode": "pip",
|
||
"bgm": "song.mp3",
|
||
"title": "新标题",
|
||
},
|
||
)
|
||
draft.id = "plan-publish"
|
||
svc._plan_repo.create(draft)
|
||
|
||
# 草稿的片段
|
||
clip1 = EditPlanClip.create(
|
||
plan_id=draft.id,
|
||
clip_type="main",
|
||
order=0,
|
||
duration=8.0,
|
||
text_content="第一段",
|
||
transition_effect="fade",
|
||
config={"playback_speed": 1.5},
|
||
)
|
||
clip1.id = "clip-pub-1"
|
||
svc._plan_clip_repo.create(clip1)
|
||
|
||
clip2 = EditPlanClip.create(
|
||
plan_id=draft.id,
|
||
clip_type="main",
|
||
order=1,
|
||
duration=12.0,
|
||
text_content="第二段",
|
||
)
|
||
clip2.id = "clip-pub-2"
|
||
svc._plan_clip_repo.create(clip2)
|
||
|
||
# 发布
|
||
result = svc.publish_template_from_draft(tpl.id, draft.id)
|
||
|
||
assert result is not None
|
||
assert result.editing_mode == "pip"
|
||
assert result.config.get("original") is None # 旧配置被覆盖
|
||
assert result.config.get("bgm") == "song.mp3"
|
||
assert result.config.get("is_template_draft") is None # 草稿标记不带过去
|
||
|
||
# 检查片段配置已更新
|
||
new_configs = svc.list_clip_configs(tpl.id)
|
||
assert len(new_configs) == 2
|
||
assert new_configs[0].order == 0
|
||
assert new_configs[0].min_duration == 8.0
|
||
assert new_configs[0].text_template == "第一段"
|
||
assert new_configs[0].config.get("playback_speed") == 1.5
|
||
assert new_configs[1].order == 1
|
||
assert new_configs[1].max_duration == 12.0
|
||
|
||
def test_publish_template_wrong_template_raises(self):
|
||
"""草稿不属于指定模板时抛错"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
tpl1 = svc.create_template(name="模板1", editing_mode="one_take")
|
||
tpl2 = svc.create_template(name="模板2", editing_mode="one_take")
|
||
|
||
# 草稿属于tpl1
|
||
draft = EditPlan.create(
|
||
template_id=tpl1.id,
|
||
name="草稿",
|
||
config={"is_template_draft": True},
|
||
)
|
||
draft.id = "plan-wrong-tpl"
|
||
svc._plan_repo.create(draft)
|
||
|
||
import pytest
|
||
|
||
with pytest.raises(ValueError, match="不属于该模板"):
|
||
svc.publish_template_from_draft(tpl2.id, draft.id)
|
||
|
||
def test_publish_template_not_draft_raises(self):
|
||
"""不是草稿的计划不能发布"""
|
||
svc = _make_service_with_plan_stubs()
|
||
from packages.domain.edit_plan import EditPlan
|
||
|
||
tpl = svc.create_template(name="非草稿模板", editing_mode="one_take")
|
||
|
||
normal_plan = EditPlan.create(
|
||
template_id=tpl.id,
|
||
name="普通计划",
|
||
config={"is_template_draft": False},
|
||
)
|
||
normal_plan.id = "plan-not-draft"
|
||
svc._plan_repo.create(normal_plan)
|
||
|
||
import pytest
|
||
|
||
with pytest.raises(ValueError, match="不是模板草稿"):
|
||
svc.publish_template_from_draft(tpl.id, normal_plan.id)
|