""" 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)