diff --git a/tests/unit/test_template_use_cases.py b/tests/unit/test_template_use_cases.py index 3045e56fe..bf074bebf 100755 --- a/tests/unit/test_template_use_cases.py +++ b/tests/unit/test_template_use_cases.py @@ -1,8 +1,9 @@ -""" -Template Use Cases 单元测试 — 剪辑计划模板 CRUD + 业务规则校验 -""" +"""Template use cases 单元测试.""" -from unittest.mock import Mock +from __future__ import annotations + +from typing import List, Optional +from unittest.mock import MagicMock import pytest @@ -20,656 +21,610 @@ from packages.application.template.use_cases import ( CountTemplatesUseCase, CreateCategoryUseCase, CreateTemplateUseCase, + DeleteCategoryUseCase, DeleteTemplateUseCase, - GetTemplateUsageUseCase, + GenerateWarning, GetTemplateUseCase, + GetTemplateUsageUseCase, ListCategoriesUseCase, ListTagsUseCase, ListTemplatesUseCase, NotFoundError, UpdateTemplateUseCase, + ValidateResult, ValidateTemplateUseCase, ValidationError, ) +from packages.domain.editing_mode import EditingMode from packages.domain.template import Template, TemplateCategory, TemplateSegment -def _make_repo(): - """创建一个 mock repository.""" - repo = Mock() - repo.list_by_user = Mock(return_value=[]) - repo.get = Mock(return_value=None) - repo.create = Mock() - repo.update = Mock() - repo.delete = Mock(return_value=False) - repo.count_by_user = Mock(return_value=0) - repo.list_segments = Mock(return_value=[]) - repo.create_segments = Mock() - repo.delete_segments_by_template = Mock(return_value=0) - repo.list_categories = Mock(return_value=[]) - repo.create_category = Mock() - repo.get_category = Mock(return_value=None) - repo.delete_category = Mock(return_value=False) - repo.copy_template = Mock() - repo.list_tags = Mock(return_value=[]) - repo.get_usage_count = Mock(return_value=0) - return repo - - -def _make_template(**kwargs) -> Template: - defaults = dict( - id="tmpl-001", - user_id="user-001", - name="测试模板", - mode="pip", - category="default", - tags=["test"], - title_config={"ai_auto_select": True}, - subtitle_config={"enabled": True}, - bgm_config={"enabled": False}, - estimated_duration=60.0, - segments=[], +def _make_template( + template_id: str = "tpl_001", + user_id: str = "user_001", + name: str = "测试模板", + mode: str = "one_take", + segments: Optional[List[TemplateSegment]] = None, + estimated_duration: float = 60.0, +) -> Template: + tpl = Template( + id=template_id, + user_id=user_id, + name=name, + mode=mode, + category="测试分类", + tags=["tag1", "tag2"], + title_config={"enabled": True}, + subtitle_config={"enabled": False}, + bgm_config={"enabled": True}, + estimated_duration=estimated_duration, ) - defaults.update(kwargs) - return Template(**defaults) + if segments is not None: + tpl.segments = segments + return tpl -# ── CreateTemplateUseCase ── +def _make_segments(count: int = 1, material_type: Optional[str] = None) -> List[TemplateSegment]: + return [ + TemplateSegment( + id=f"seg_{i}", + template_id="tpl_001", + segment_order=i, + duration_min=3.0, + duration_max=8.0, + material_type=material_type, + ) + for i in range(count) + ] class TestCreateTemplateUseCase: - @pytest.fixture - def repo(self): - return _make_repo() + def test_creates_template_with_segments(self) -> None: + repo = MagicMock() + repo.create.side_effect = lambda t: t # 返回传入的template + repo.create_segments.return_value = None - @pytest.fixture - def use_case(self, repo): - return CreateTemplateUseCase(repo) - - def test_create_basic_template(self, use_case, repo): - """创建基础模板(无片段).""" - repo.create.side_effect = lambda t: t # 返回传入的 template - - command = CreateTemplateCommand( - user_id="user-001", - name="画中画模板", - mode="pip", - category="vlog", - tags=["vlog", "pip"], - estimated_duration=90.0, - ) - result = use_case.execute(command) - - assert result.name == "画中画模板" - assert result.mode == "pip" - assert result.user_id == "user-001" - repo.create.assert_called_once() - - def test_create_with_segments(self, use_case, repo): - """创建模板并附带片段.""" - repo.create.side_effect = lambda t: t - repo.create_segments.side_effect = lambda segs: segs - - command = CreateTemplateCommand( - user_id="user-001", - name="口播混剪模板", - mode="voice_over", + use_case = CreateTemplateUseCase(repo) + cmd = CreateTemplateCommand( + user_id="user_001", + name="新模板", + mode=EditingMode.ONE_TAKE.value, + category="分类A", + tags=["t1", "t2"], segments=[ - SegmentCommand(segment_order=1, duration_min=5, duration_max=15, material_type="人物"), - SegmentCommand(segment_order=2, duration_min=10, duration_max=30, material_type="场景"), + SegmentCommand(segment_order=0, duration_min=2.0, duration_max=5.0), + SegmentCommand(segment_order=1, duration_min=3.0, duration_max=6.0), ], ) - result = use_case.execute(command) + result = use_case.execute(cmd) + + assert result.name == "新模板" + assert result.mode == EditingMode.ONE_TAKE.value assert len(result.segments) == 2 - assert result.segments[0].material_type == "人物" + assert result.segments[0].segment_order == 0 + assert result.segments[1].segment_order == 1 + repo.create.assert_called_once() repo.create_segments.assert_called_once() - def test_create_invalid_mode_raises(self, use_case): - """无效剪辑模式应抛出 ValidationError.""" - command = CreateTemplateCommand( - user_id="user-001", - name="无效模板", + def test_invalid_mode_raises_validation_error(self) -> None: + repo = MagicMock() + use_case = CreateTemplateUseCase(repo) + cmd = CreateTemplateCommand( + user_id="user_001", + name="测试", mode="invalid_mode", + segments=[], ) + with pytest.raises(ValidationError, match="无效的剪辑模式"): - use_case.execute(command) + use_case.execute(cmd) + def test_creates_without_segments(self) -> None: + repo = MagicMock() + repo.create.side_effect = lambda t: t + repo.create_segments.return_value = None -# ── UpdateTemplateUseCase ── - - -class TestUpdateTemplateUseCase: - @pytest.fixture - def repo(self): - return _make_repo() - - @pytest.fixture - def use_case(self, repo): - return UpdateTemplateUseCase(repo) - - def test_update_name(self, use_case, repo): - """更新模板名称.""" - existing = _make_template() - repo.get.return_value = existing - repo.update.side_effect = lambda t: t - - command = UpdateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - name="新名称", + use_case = CreateTemplateUseCase(repo) + cmd = CreateTemplateCommand( + user_id="user_001", + name="空片段模板", + mode=EditingMode.ONE_TAKE.value, + segments=[], ) - result = use_case.execute(command) - assert result.name == "新名称" - repo.update.assert_called_once() + result = use_case.execute(cmd) + assert len(result.segments) == 0 + repo.create_segments.assert_called_once_with([]) - def test_update_not_found_raises(self, use_case, repo): - """模板不存在时抛出 NotFoundError.""" - repo.get.return_value = None + def test_generates_uuid_for_template_and_segments(self) -> None: + repo = MagicMock() + repo.create.side_effect = lambda t: t + repo.create_segments.return_value = None - command = UpdateTemplateCommand( - template_id="nonexistent", - user_id="user-001", - name="新名称", - ) - with pytest.raises(NotFoundError): - use_case.execute(command) - - def test_update_invalid_mode_raises(self, use_case, repo): - """更新为无效模式时抛出 ValidationError.""" - existing = _make_template() - repo.get.return_value = existing - - command = UpdateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - mode="bad_mode", - ) - with pytest.raises(ValidationError, match="无效的剪辑模式"): - use_case.execute(command) - - def test_replace_segments(self, use_case, repo): - """替换片段列表.""" - existing = _make_template() - repo.get.return_value = existing - repo.update.side_effect = lambda t: t - repo.create_segments.side_effect = lambda segs: segs - - command = UpdateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", + use_case = CreateTemplateUseCase(repo) + cmd = CreateTemplateCommand( + user_id="user_001", + name="UUID测试", + mode=EditingMode.VOICE_OVER.value, segments=[ - SegmentCommand(segment_order=1, duration_min=5, duration_max=20, material_type=None), + SegmentCommand(segment_order=0, duration_min=1.0, duration_max=3.0, material_type="人物"), ], ) - result = use_case.execute(command) - repo.delete_segments_by_template.assert_called_once_with("tmpl-001") - repo.create_segments.assert_called_once() - assert len(result.segments) == 1 - - -# ── ValidateTemplateUseCase — 业务规则校验 ── - - -class TestValidateTemplateUseCase: - @pytest.fixture - def repo(self): - return _make_repo() - - @pytest.fixture - def use_case(self, repo): - return ValidateTemplateUseCase(repo) - - def test_one_take_with_one_segment_ok(self, use_case, repo): - """一镜到底 + 恰好 1 个片段 → 通过.""" - seg = TemplateSegment( - id="seg-001", - template_id="tmpl-001", - segment_order=1, - duration_min=0, - duration_max=60, - ) - template = _make_template(mode="one_take", segments=[seg]) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - ) - result = use_case.execute(command) - - assert result.template.mode == "one_take" - assert result.warnings == [] - - def test_one_take_with_two_segments_raises(self, use_case, repo): - """一镜到底 + 2 个片段 → ValidationError.""" - segs = [ - TemplateSegment(id=f"seg-{i}", template_id="tmpl-001", segment_order=i, duration_min=0, duration_max=30) - for i in (1, 2) - ] - template = _make_template(mode="one_take", segments=segs) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - ) - with pytest.raises(ValidationError, match="一镜到底模式必须恰好有 1 个片段"): - use_case.execute(command) - - def test_voice_over_all_segments_have_material_type_ok(self, use_case, repo): - """口播+B-roll + 所有片段都有 material_type → 通过.""" - segs = [ - TemplateSegment( - id="seg-1", - template_id="tmpl-001", - segment_order=1, - duration_min=5, - duration_max=15, - material_type="人物", - ), - TemplateSegment( - id="seg-2", - template_id="tmpl-001", - segment_order=2, - duration_min=10, - duration_max=30, - material_type="场景", - ), - ] - template = _make_template(mode="voice_over", segments=segs) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - ) - result = use_case.execute(command) - assert result.warnings == [] - - def test_voice_over_missing_material_type_raises(self, use_case, repo): - """口播+B-roll + 某片段缺少 material_type → ValidationError.""" - segs = [ - TemplateSegment( - id="seg-1", - template_id="tmpl-001", - segment_order=1, - duration_min=5, - duration_max=15, - material_type="人物", - ), - TemplateSegment( - id="seg-2", - template_id="tmpl-001", - segment_order=2, - duration_min=10, - duration_max=30, - material_type=None, - ), # 缺失 - ] - template = _make_template(mode="voice_over", segments=segs) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - ) - with pytest.raises(ValidationError, match="material_type"): - use_case.execute(command) - - def test_voiceover_duration_within_tolerance_no_warning(self, use_case, repo): - """配音时长在 ±30% 以内 → 无警告.""" - template = _make_template(estimated_duration=60.0) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - voiceover_duration=70.0, # 70/60 = 1.167, within ±30% - ) - result = use_case.execute(command) - assert result.warnings == [] - - def test_voiceover_duration_exceeds_tolerance_warning(self, use_case, repo): - """配音时长超过 ±30% → 警告.""" - template = _make_template(estimated_duration=60.0) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - voiceover_duration=100.0, # 100/60 = 1.667, exceeds +30% - ) - result = use_case.execute(command) - - assert len(result.warnings) == 1 - assert result.warnings[0].code == "voiceover_duration_mismatch" - - def test_voiceover_duration_too_short_warning(self, use_case, repo): - """配音时长过短(< 70%)→ 警告.""" - template = _make_template(estimated_duration=60.0) - repo.get.return_value = template - - command = ValidateTemplateCommand( - template_id="tmpl-001", - user_id="user-001", - voiceover_duration=30.0, # 30/60 = 0.5, below -30% - ) - result = use_case.execute(command) - - assert len(result.warnings) == 1 - assert result.warnings[0].code == "voiceover_duration_mismatch" - - def test_template_not_found_raises(self, use_case, repo): - """模板不存在 → NotFoundError.""" - repo.get.return_value = None - - command = ValidateTemplateCommand( - template_id="nonexistent", - user_id="user-001", - ) - with pytest.raises(NotFoundError): - use_case.execute(command) - - -# ── Category Use Cases ── - - -class TestCategoryUseCases: - @pytest.fixture - def repo(self): - return _make_repo() - - def test_create_category(self, repo): - repo.create_category.side_effect = lambda c: c - - use_case = CreateCategoryUseCase(repo) - command = CreateCategoryCommand(user_id="user-001", name="Vlog") - result = use_case.execute(command) - - assert result.name == "Vlog" - repo.create_category.assert_called_once() - - def test_list_categories(self, repo): - categories = [ - TemplateCategory(id="cat-1", user_id="user-001", name="Vlog"), - TemplateCategory(id="cat-2", user_id="user-001", name="教程"), - ] - repo.list_categories.return_value = categories - - use_case = ListCategoriesUseCase(repo) - result = use_case.execute("user-001") - - assert len(result) == 2 - assert result[0].name == "Vlog" - - def test_delete_category_not_found(self, repo): - repo.delete_category.return_value = False - - use_case = DeleteTemplateUseCase(repo) - result = use_case.execute("nonexistent", "user-001") - assert result is False - - -# ── ListTemplatesUseCase ── + result = use_case.execute(cmd) + assert len(result.id) == 32 # uuid hex + assert len(result.segments[0].id) == 32 + assert result.segments[0].template_id == result.id class TestListTemplatesUseCase: - def test_list_returns_templates(self): - repo = _make_repo() - templates = [_make_template(id=f"t-{i}") for i in range(3)] - repo.list_by_user.return_value = templates + def test_list_without_filter(self) -> None: + repo = MagicMock() + expected = [_make_template("t1"), _make_template("t2")] + repo.list_by_user.return_value = expected use_case = ListTemplatesUseCase(repo) - result = use_case.execute("user-001", skip=0, limit=50) + result = use_case.execute("user_001", skip=0, limit=10) - assert len(result) == 3 - repo.list_by_user.assert_called_once_with("user-001", skip=0, limit=50) + assert len(result) == 2 + repo.list_by_user.assert_called_once_with("user_001", skip=0, limit=10) + + def test_list_with_filter(self) -> None: + repo = MagicMock() + expected = [_make_template("t1")] + repo.list_by_user.return_value = expected + + use_case = ListTemplatesUseCase(repo) + f = ListTemplatesFilter(category="分类A", tag="t1", keyword="测试", mode="one_take") + result = use_case.execute("user_001", skip=0, limit=10, filter=f) + + assert len(result) == 1 + repo.list_by_user.assert_called_once_with( + "user_001", + skip=0, + limit=10, + category="分类A", + tag="t1", + keyword="测试", + mode="one_take", + ) -# ── GetTemplateUseCase ── +class TestCountTemplatesUseCase: + def test_count_without_filter(self) -> None: + repo = MagicMock() + repo.count_by_user.return_value = 42 + + use_case = CountTemplatesUseCase(repo) + result = use_case.execute("user_001") + + assert result == 42 + repo.count_by_user.assert_called_once_with("user_001") + + def test_count_with_filter(self) -> None: + repo = MagicMock() + repo.count_by_user.return_value = 5 + + use_case = CountTemplatesUseCase(repo) + f = ListTemplatesFilter(category="分类A") + result = use_case.execute("user_001", filter=f) + + assert result == 5 + repo.count_by_user.assert_called_once_with( + "user_001", + category="分类A", + tag=None, + keyword=None, + mode=None, + ) class TestGetTemplateUseCase: - def test_get_existing(self): - repo = _make_repo() - template = _make_template() - repo.get.return_value = template + def test_returns_template_when_found(self) -> None: + repo = MagicMock() + expected = _make_template() + repo.get.return_value = expected use_case = GetTemplateUseCase(repo) - result = use_case.execute("tmpl-001", "user-001") + result = use_case.execute("tpl_001", "user_001") - assert result.id == "tmpl-001" + assert result is expected + repo.get.assert_called_once_with("tpl_001", "user_001") - def test_get_nonexistent_returns_none(self): - repo = _make_repo() + def test_returns_none_when_not_found(self) -> None: + repo = MagicMock() repo.get.return_value = None use_case = GetTemplateUseCase(repo) - result = use_case.execute("nonexistent", "user-001") + result = use_case.execute("nonexistent", "user_001") assert result is None -# ── CopyTemplateUseCase ── +class TestUpdateTemplateUseCase: + def test_updates_name_and_tags(self) -> None: + repo = MagicMock() + existing = _make_template() + existing.segments = _make_segments(2) + repo.get.return_value = existing + repo.update.side_effect = lambda t: t + repo.list_segments.return_value = existing.segments + + use_case = UpdateTemplateUseCase(repo) + cmd = UpdateTemplateCommand( + template_id="tpl_001", + user_id="user_001", + name="新名字", + tags=["new_tag"], + ) + + result = use_case.execute(cmd) + assert result.name == "新名字" + assert result.tags == ["new_tag"] + # mode没变 + assert result.mode == EditingMode.ONE_TAKE.value + repo.update.assert_called_once() + + def test_not_found_raises(self) -> None: + repo = MagicMock() + repo.get.return_value = None + + use_case = UpdateTemplateUseCase(repo) + cmd = UpdateTemplateCommand(template_id="nonexistent", user_id="user_001", name="x") + + with pytest.raises(NotFoundError): + use_case.execute(cmd) + + def test_invalid_mode_raises(self) -> None: + repo = MagicMock() + repo.get.return_value = _make_template() + + use_case = UpdateTemplateUseCase(repo) + cmd = UpdateTemplateCommand( + template_id="tpl_001", + user_id="user_001", + mode="invalid", + ) + + with pytest.raises(ValidationError, match="无效的剪辑模式"): + use_case.execute(cmd) + + def test_replaces_segments_when_provided(self) -> None: + repo = MagicMock() + existing = _make_template() + existing.segments = _make_segments(2) + repo.get.return_value = existing + repo.update.side_effect = lambda t: t + repo.delete_segments_by_template.return_value = None + repo.create_segments.return_value = None + + use_case = UpdateTemplateUseCase(repo) + cmd = UpdateTemplateCommand( + template_id="tpl_001", + user_id="user_001", + segments=[ + SegmentCommand(segment_order=0, duration_min=1.0, duration_max=2.0), + SegmentCommand(segment_order=1, duration_min=3.0, duration_max=4.0), + SegmentCommand(segment_order=2, duration_min=5.0, duration_max=6.0), + ], + ) + + result = use_case.execute(cmd) + assert len(result.segments) == 3 + repo.delete_segments_by_template.assert_called_once_with("tpl_001") + repo.create_segments.assert_called_once() + + def test_no_segments_keeps_existing(self) -> None: + repo = MagicMock() + existing = _make_template() + existing.segments = _make_segments(3) + repo.get.return_value = existing + repo.update.side_effect = lambda t: t + repo.list_segments.return_value = existing.segments + + use_case = UpdateTemplateUseCase(repo) + cmd = UpdateTemplateCommand( + template_id="tpl_001", + user_id="user_001", + name="只改名字", + ) + + result = use_case.execute(cmd) + assert len(result.segments) == 3 + repo.delete_segments_by_template.assert_not_called() + repo.create_segments.assert_not_called() + repo.list_segments.assert_called_once_with("tpl_001") + + +class TestDeleteTemplateUseCase: + def test_delete_success(self) -> None: + repo = MagicMock() + repo.delete.return_value = True + + use_case = DeleteTemplateUseCase(repo) + result = use_case.execute("tpl_001", "user_001") + + assert result is True + repo.delete.assert_called_once_with("tpl_001", "user_001") + + def test_delete_not_found(self) -> None: + repo = MagicMock() + repo.delete.return_value = False + + use_case = DeleteTemplateUseCase(repo) + result = use_case.execute("nonexistent", "user_001") + + assert result is False class TestCopyTemplateUseCase: - @pytest.fixture - def repo(self): - repo = _make_repo() - source = _make_template( - id="tmpl-src", - name="源模板", - segments=[ - TemplateSegment( - id="seg-1", - template_id="tmpl-src", - segment_order=0, - duration_min=5.0, - duration_max=10.0, - material_type=None, - ), - ], - ) - repo.get = Mock(return_value=source) + def test_copy_success(self) -> None: + repo = MagicMock() + original = _make_template(name="原模板") + repo.get.return_value = original + copied = _make_template(template_id="copied_001", name="原模板 副本") + repo.copy_template.return_value = copied - def _copy_side_effect(template_id, user_id, new_name): - return _make_template( - id="tmpl-copied", - user_id=user_id, - name=new_name, - segments=[ - TemplateSegment( - id="seg-copied", - template_id="tmpl-copied", - segment_order=0, - duration_min=5.0, - duration_max=10.0, - material_type=None, - ) - ], - ) - - repo.copy_template = Mock(side_effect=_copy_side_effect) - return repo - - @pytest.fixture - def use_case(self, repo): - return CopyTemplateUseCase(repo) - - def test_copy_success(self, use_case, repo): - command = CopyTemplateCommand( - template_id="tmpl-src", - user_id="user-001", - new_name="复制的模板", - ) - result = use_case.execute(command) - - assert result.id == "tmpl-copied" - assert result.name == "复制的模板" - assert len(result.segments) == 1 - repo.copy_template.assert_called_once_with( - "tmpl-src", - "user-001", - "复制的模板", + use_case = CopyTemplateUseCase(repo) + cmd = CopyTemplateCommand( + template_id="tpl_001", + user_id="user_001", + new_name="原模板 副本", ) - def test_copy_not_found_raises(self, use_case, repo): - repo.get = Mock(return_value=None) - command = CopyTemplateCommand( - template_id="tmpl-nonexist", - user_id="user-001", - new_name="新名字", - ) + result = use_case.execute(cmd) + assert result.name == "原模板 副本" + repo.copy_template.assert_called_once_with("tpl_001", "user_001", "原模板 副本") + + def test_not_found_raises(self) -> None: + repo = MagicMock() + repo.get.return_value = None + + use_case = CopyTemplateUseCase(repo) + cmd = CopyTemplateCommand(template_id="no", user_id="u1", new_name="x") + with pytest.raises(NotFoundError): - use_case.execute(command) + use_case.execute(cmd) - def test_copy_empty_name_raises(self, use_case, repo): - command = CopyTemplateCommand( - template_id="tmpl-src", - user_id="user-001", - new_name=" ", - ) - with pytest.raises(ValidationError): - use_case.execute(command) + def test_empty_name_raises(self) -> None: + repo = MagicMock() + repo.get.return_value = _make_template() + + use_case = CopyTemplateUseCase(repo) + cmd = CopyTemplateCommand(template_id="t1", user_id="u1", new_name=" ") + + with pytest.raises(ValidationError, match="名称不能为空"): + use_case.execute(cmd) + + def test_name_stripped(self) -> None: + repo = MagicMock() + repo.get.return_value = _make_template() + repo.copy_template.return_value = _make_template(name="新名字") + + use_case = CopyTemplateUseCase(repo) + cmd = CopyTemplateCommand(template_id="t1", user_id="u1", new_name=" 新名字 ") + + use_case.execute(cmd) + repo.copy_template.assert_called_once_with("t1", "u1", "新名字") -# ── ListTemplatesUseCase (filter) ── +class TestValidateTemplateUseCase: + def test_one_take_with_one_segment_passes(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.ONE_TAKE.value) + tpl.segments = _make_segments(1) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="tpl_001", user_id="user_001") + result = use_case.execute(cmd) + + assert isinstance(result, ValidateResult) + assert result.template is tpl + assert len(result.warnings) == 0 + + def test_one_take_with_multiple_segments_raises(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.ONE_TAKE.value) + tpl.segments = _make_segments(3) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="tpl_001", user_id="user_001") + + with pytest.raises(ValidationError, match="恰好有 1 个片段"): + use_case.execute(cmd) + + def test_voice_over_with_valid_material_types_passes(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.VOICE_OVER.value, estimated_duration=30.0) + tpl.segments = [ + TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=3, duration_max=5, material_type="人物"), + TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=3, duration_max=5, material_type="场景"), + ] + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="tpl_001", user_id="user_001") + result = use_case.execute(cmd) + + assert len(result.warnings) == 0 + + def test_voice_over_missing_material_type_raises(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.VOICE_OVER.value) + tpl.segments = [ + TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=3, duration_max=5, material_type=None), + ] + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="tpl_001", user_id="user_001") + + with pytest.raises(ValidationError, match="material_type"): + use_case.execute(cmd) + + def test_voice_over_invalid_material_type_raises(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.VOICE_OVER.value) + tpl.segments = [ + TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=3, duration_max=5, material_type="动物"), + ] + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="tpl_001", user_id="user_001") + + with pytest.raises(ValidationError, match="material_type"): + use_case.execute(cmd) + + def test_voiceover_duration_within_range_no_warning(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.ONE_TAKE.value, estimated_duration=60.0) + tpl.segments = _make_segments(1) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + # 65s vs 60s = 1.08 ratio,在±30%内 + cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=65.0) + result = use_case.execute(cmd) + + assert len(result.warnings) == 0 + + def test_voiceover_duration_too_short_warns(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.ONE_TAKE.value, estimated_duration=60.0) + tpl.segments = _make_segments(1) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + # 20s vs 60s = 0.33 ratio,超过±30% + cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=20.0) + result = use_case.execute(cmd) + + assert len(result.warnings) == 1 + assert result.warnings[0].code == "voiceover_duration_mismatch" + assert "偏差超过" in result.warnings[0].message + assert result.warnings[0].details["ratio"] < 0.7 + + def test_voiceover_duration_too_long_warns(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.ONE_TAKE.value, estimated_duration=60.0) + tpl.segments = _make_segments(1) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + # 100s vs 60s = 1.67 ratio,超过±30% + cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=100.0) + result = use_case.execute(cmd) + + assert len(result.warnings) == 1 + assert result.warnings[0].code == "voiceover_duration_mismatch" + assert result.warnings[0].details["ratio"] > 1.3 + + def test_zero_estimated_duration_no_warning(self) -> None: + repo = MagicMock() + tpl = _make_template(mode=EditingMode.ONE_TAKE.value, estimated_duration=0.0) + tpl.segments = _make_segments(1) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=50.0) + result = use_case.execute(cmd) + + # estimated_duration=0不做偏差检查 + assert len(result.warnings) == 0 + + def test_no_voiceover_duration_no_warning(self) -> None: + repo = MagicMock() + tpl = _make_template(estimated_duration=60.0) + tpl.segments = _make_segments(1) + repo.get.return_value = tpl + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") # 不传voiceover_duration + result = use_case.execute(cmd) + + assert len(result.warnings) == 0 + + def test_not_found_raises(self) -> None: + repo = MagicMock() + repo.get.return_value = None + + use_case = ValidateTemplateUseCase(repo) + cmd = ValidateTemplateCommand(template_id="no", user_id="u1") + + with pytest.raises(NotFoundError): + use_case.execute(cmd) -class TestListTemplatesUseCaseWithFilter: - def test_list_with_category_filter(self): - repo = _make_repo() - repo.list_by_user = Mock(return_value=[]) - use_case = ListTemplatesUseCase(repo) - f = ListTemplatesFilter(category="vlog") +class TestCategoryUseCases: + def test_create_category(self) -> None: + repo = MagicMock() + cat = TemplateCategory(id="cat_001", user_id="u1", name="新分类") + repo.create_category.return_value = cat - use_case.execute("user-001", skip=0, limit=10, filter=f) + use_case = CreateCategoryUseCase(repo) + cmd = CreateCategoryCommand(user_id="u1", name="新分类") + result = use_case.execute(cmd) - repo.list_by_user.assert_called_once() - call_kwargs = repo.list_by_user.call_args - assert call_kwargs[1]["category"] == "vlog" + assert result.name == "新分类" + repo.create_category.assert_called_once() - def test_list_with_tag_filter(self): - repo = _make_repo() - repo.list_by_user = Mock(return_value=[]) - use_case = ListTemplatesUseCase(repo) - f = ListTemplatesFilter(tag="热门") + def test_list_categories(self) -> None: + repo = MagicMock() + expected = [TemplateCategory(id="c1", user_id="u1", name="A")] + repo.list_categories.return_value = expected - use_case.execute("user-001", filter=f) + use_case = ListCategoriesUseCase(repo) + result = use_case.execute("u1") - call_kwargs = repo.list_by_user.call_args - assert call_kwargs[1]["tag"] == "热门" + assert result == expected + repo.list_categories.assert_called_once_with("u1") - def test_list_with_keyword_filter(self): - repo = _make_repo() - repo.list_by_user = Mock(return_value=[]) - use_case = ListTemplatesUseCase(repo) - f = ListTemplatesFilter(keyword="vlog") + def test_delete_category(self) -> None: + repo = MagicMock() + repo.delete_category.return_value = True - use_case.execute("user-001", filter=f) + use_case = DeleteCategoryUseCase(repo) + result = use_case.execute("cat_001", "u1") - call_kwargs = repo.list_by_user.call_args - assert call_kwargs[1]["keyword"] == "vlog" - - def test_list_with_mode_filter(self): - repo = _make_repo() - repo.list_by_user = Mock(return_value=[]) - use_case = ListTemplatesUseCase(repo) - f = ListTemplatesFilter(mode="one_take") - - use_case.execute("user-001", filter=f) - - call_kwargs = repo.list_by_user.call_args - assert call_kwargs[1]["mode"] == "one_take" - - def test_list_without_filter_uses_defaults(self): - repo = _make_repo() - repo.list_by_user = Mock(return_value=[]) - use_case = ListTemplatesUseCase(repo) - - use_case.execute("user-001", skip=0, limit=50) - - call_args = repo.list_by_user.call_args - assert call_args[0][0] == "user-001" - assert call_args[1]["skip"] == 0 - assert call_args[1]["limit"] == 50 - - -# ── CountTemplatesUseCase ── - - -class TestCountTemplatesUseCase: - def test_count_without_filter(self): - repo = _make_repo() - repo.count_by_user = Mock(return_value=5) - use_case = CountTemplatesUseCase(repo) - - result = use_case.execute("user-001") - - assert result == 5 - repo.count_by_user.assert_called_once_with("user-001") - - def test_count_with_filter(self): - repo = _make_repo() - repo.count_by_user = Mock(return_value=2) - use_case = CountTemplatesUseCase(repo) - f = ListTemplatesFilter(category="vlog", tag="热门") - - result = use_case.execute("user-001", filter=f) - - assert result == 2 - call_kwargs = repo.count_by_user.call_args - assert call_kwargs[1]["category"] == "vlog" - assert call_kwargs[1]["tag"] == "热门" - - -# ── ListTagsUseCase ── + assert result is True + repo.delete_category.assert_called_once_with("cat_001", "u1") class TestListTagsUseCase: - def test_list_tags_returns_sorted(self): - repo = _make_repo() - repo.list_tags = Mock(return_value=["vlog", "热门", "教程"]) + def test_returns_tags_list(self) -> None: + repo = MagicMock() + repo.list_tags.return_value = ["tag1", "tag2", "tag3"] + use_case = ListTagsUseCase(repo) + result = use_case.execute("u1") - result = use_case.execute("user-001") - - assert result == ["vlog", "热门", "教程"] - repo.list_tags.assert_called_once_with("user-001") - - def test_list_tags_empty(self): - repo = _make_repo() - repo.list_tags = Mock(return_value=[]) - use_case = ListTagsUseCase(repo) - - result = use_case.execute("user-001") - - assert result == [] - - -# ── GetTemplateUsageUseCase ── + assert result == ["tag1", "tag2", "tag3"] + repo.list_tags.assert_called_once_with("u1") class TestGetTemplateUsageUseCase: - def test_get_usage_count(self): - repo = _make_repo() - repo.get_usage_count = Mock(return_value=3) + def test_returns_usage_count(self) -> None: + repo = MagicMock() + repo.get_usage_count.return_value = 15 + use_case = GetTemplateUsageUseCase(repo) + result = use_case.execute("tpl_001") - result = use_case.execute("tmpl-001") + assert result == 15 + repo.get_usage_count.assert_called_once_with("tpl_001") - assert result == 3 - repo.get_usage_count.assert_called_once_with("tmpl-001") - def test_get_usage_zero(self): - repo = _make_repo() - repo.get_usage_count = Mock(return_value=0) - use_case = GetTemplateUsageUseCase(repo) +class TestGenerateWarning: + def test_warning_default_details(self) -> None: + w = GenerateWarning(code="test_code", message="test message") + assert w.code == "test_code" + assert w.message == "test message" + assert w.details == {} - result = use_case.execute("tmpl-001") - - assert result == 0 + def test_warning_with_details(self) -> None: + w = GenerateWarning(code="test", message="msg", details={"key": "value"}) + assert w.details == {"key": "value"}