"""模板 Use Cases 单元测试 — wave216""" from __future__ import annotations from unittest.mock import MagicMock import pytest from packages.application.template.commands import ( CopyTemplateCommand, CreateCategoryCommand, CreateTemplateCommand, ListTemplatesFilter, SegmentCommand, UpdateTemplateCommand, ValidateTemplateCommand, ) from packages.application.template.use_cases import ( CopyTemplateUseCase, CountTemplatesUseCase, CreateCategoryUseCase, CreateTemplateUseCase, DeleteCategoryUseCase, DeleteTemplateUseCase, GenerateWarning, GetTemplateUsageUseCase, GetTemplateUseCase, ListCategoriesUseCase, ListTagsUseCase, ListTemplatesUseCase, UpdateTemplateUseCase, ValidateResult, ValidateTemplateUseCase, ) from packages.domain.editing_mode import EditingMode from packages.domain.exceptions import NotFoundError, ValidationError from packages.domain.template import Template, TemplateCategory, TemplateSegment # ── helpers ────────────────────────────────────────────────────────────────── def _make_template( template_id="t1", user_id="u1", name="测试模板", mode=EditingMode.ONE_TAKE.value, category="", estimated_duration=30.0, segments=None, ): if segments is None: segments = [ TemplateSegment( id="s1", template_id=template_id, segment_order=1, duration_min=5.0, duration_max=10.0, ) ] return Template( id=template_id, user_id=user_id, name=name, mode=mode, category=category, estimated_duration=estimated_duration, segments=segments, ) def _make_segments(n, *, start_order=1, material_type=None): return [ TemplateSegment( id=f"s{i}", template_id="t1", segment_order=start_order + i - 1, duration_min=5.0, duration_max=10.0, material_type=material_type, ) for i in range(1, n + 1) ] # ── CreateTemplateUseCase ──────────────────────────────────────────────────── class TestCreateTemplateUseCase: def test_create_success(self): repo = MagicMock() repo.create.side_effect = lambda t: t repo.create_segments.return_value = None uc = CreateTemplateUseCase(repo) cmd = CreateTemplateCommand( user_id="u1", name="我的模板", mode=EditingMode.ONE_TAKE.value, segments=[SegmentCommand(segment_order=1, duration_min=5, duration_max=10)], ) result = uc.execute(cmd) assert result.name == "我的模板" assert result.mode == EditingMode.ONE_TAKE.value assert result.user_id == "u1" assert len(result.segments) == 1 repo.create.assert_called_once() repo.create_segments.assert_called_once() def test_create_invalid_mode_raises(self): repo = MagicMock() uc = CreateTemplateUseCase(repo) cmd = CreateTemplateCommand( user_id="u1", name="test", mode="invalid_mode", ) with pytest.raises(ValidationError, match="无效的剪辑模式"): uc.execute(cmd) def test_create_with_multiple_segments(self): repo = MagicMock() repo.create.side_effect = lambda t: t repo.create_segments.return_value = None uc = CreateTemplateUseCase(repo) cmd = CreateTemplateCommand( user_id="u1", name="多片段模板", mode=EditingMode.VOICE_OVER.value, segments=[ SegmentCommand(segment_order=1, duration_min=3, duration_max=5, material_type="人物"), SegmentCommand(segment_order=2, duration_min=5, duration_max=8, material_type="场景"), ], ) result = uc.execute(cmd) assert len(result.segments) == 2 assert result.segments[0].segment_order == 1 assert result.segments[1].segment_order == 2 def test_create_with_empty_segments(self): repo = MagicMock() repo.create.side_effect = lambda t: t repo.create_segments.return_value = None uc = CreateTemplateUseCase(repo) cmd = CreateTemplateCommand( user_id="u1", name="无片段模板", mode=EditingMode.PIP.value, ) result = uc.execute(cmd) assert len(result.segments) == 0 repo.create_segments.assert_called_once_with([]) # ── ListTemplatesUseCase ──────────────────────────────────────────────────── class TestListTemplatesUseCase: def test_list_no_filter(self): templates = [_make_template("t1"), _make_template("t2")] repo = MagicMock() repo.list_by_user.return_value = templates uc = ListTemplatesUseCase(repo) result = uc.execute("u1", skip=0, limit=10) assert len(result) == 2 repo.list_by_user.assert_called_once_with("u1", skip=0, limit=10) def test_list_with_filter(self): templates = [_make_template("t1")] repo = MagicMock() repo.list_by_user.return_value = templates uc = ListTemplatesUseCase(repo) f = ListTemplatesFilter(category="cat1", tag="tag1", keyword="test", mode="one_take") result = uc.execute("u1", filter=f) assert len(result) == 1 repo.list_by_user.assert_called_once_with( "u1", skip=0, limit=50, category="cat1", tag="tag1", keyword="test", mode="one_take", ) def test_list_pagination(self): repo = MagicMock() repo.list_by_user.return_value = [] uc = ListTemplatesUseCase(repo) uc.execute("u1", skip=20, limit=10) repo.list_by_user.assert_called_once_with("u1", skip=20, limit=10) # ── CountTemplatesUseCase ─────────────────────────────────────────────────── class TestCountTemplatesUseCase: def test_count_no_filter(self): repo = MagicMock() repo.count_by_user.return_value = 42 uc = CountTemplatesUseCase(repo) result = uc.execute("u1") assert result == 42 repo.count_by_user.assert_called_once_with("u1") def test_count_with_filter(self): repo = MagicMock() repo.count_by_user.return_value = 5 uc = CountTemplatesUseCase(repo) f = ListTemplatesFilter(category="cat1", tag="tag1", keyword="kw", mode="pip") result = uc.execute("u1", filter=f) assert result == 5 repo.count_by_user.assert_called_once_with( "u1", category="cat1", tag="tag1", keyword="kw", mode="pip", ) # ── GetTemplateUseCase ────────────────────────────────────────────────────── class TestGetTemplateUseCase: def test_get_found(self): template = _make_template() repo = MagicMock() repo.get.return_value = template uc = GetTemplateUseCase(repo) result = uc.execute("t1", "u1") assert result.id == "t1" repo.get.assert_called_once_with("t1", "u1") def test_get_not_found(self): repo = MagicMock() repo.get.return_value = None uc = GetTemplateUseCase(repo) result = uc.execute("nonexistent", "u1") assert result is None # ── UpdateTemplateUseCase ─────────────────────────────────────────────────── class TestUpdateTemplateUseCase: def test_update_name(self): existing = _make_template(name="old") repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing repo.list_segments.return_value = existing.segments uc = UpdateTemplateUseCase(repo) cmd = UpdateTemplateCommand(template_id="t1", user_id="u1", name="new") result = uc.execute(cmd) assert result.name == "new" repo.update.assert_called_once() def test_update_mode(self): existing = _make_template(mode=EditingMode.ONE_TAKE.value) repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing repo.list_segments.return_value = existing.segments uc = UpdateTemplateUseCase(repo) cmd = UpdateTemplateCommand(template_id="t1", user_id="u1", mode=EditingMode.PIP.value) result = uc.execute(cmd) assert result.mode == EditingMode.PIP.value def test_update_invalid_mode_raises(self): existing = _make_template() repo = MagicMock() repo.get.return_value = existing uc = UpdateTemplateUseCase(repo) cmd = UpdateTemplateCommand(template_id="t1", user_id="u1", mode="bad") with pytest.raises(ValidationError, match="无效的剪辑模式"): uc.execute(cmd) def test_update_not_found_raises(self): repo = MagicMock() repo.get.return_value = None uc = UpdateTemplateUseCase(repo) cmd = UpdateTemplateCommand(template_id="t999", user_id="u1", name="x") with pytest.raises(NotFoundError): uc.execute(cmd) def test_update_segments(self): existing = _make_template(segments=_make_segments(1)) repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing repo.delete_segments_by_template.return_value = None repo.create_segments.return_value = None uc = UpdateTemplateUseCase(repo) cmd = UpdateTemplateCommand( template_id="t1", user_id="u1", segments=[ SegmentCommand(segment_order=1, duration_min=2, duration_max=5), SegmentCommand(segment_order=2, duration_min=3, duration_max=6), ], ) result = uc.execute(cmd) repo.delete_segments_by_template.assert_called_once_with("t1") repo.create_segments.assert_called_once() assert len(result.segments) == 2 def test_update_none_fields_not_modified(self): existing = _make_template(name="keep_name", category="keep_cat") repo = MagicMock() repo.get.return_value = existing repo.update.return_value = existing repo.list_segments.return_value = existing.segments uc = UpdateTemplateUseCase(repo) # 只传 name=None, category=None 表示不修改 cmd = UpdateTemplateCommand(template_id="t1", user_id="u1") result = uc.execute(cmd) assert result.name == "keep_name" assert result.category == "keep_cat" # ── DeleteTemplateUseCase ─────────────────────────────────────────────────── class TestDeleteTemplateUseCase: def test_delete_success(self): repo = MagicMock() repo.delete.return_value = True uc = DeleteTemplateUseCase(repo) result = uc.execute("t1", "u1") assert result is True repo.delete.assert_called_once_with("t1", "u1") def test_delete_not_found(self): repo = MagicMock() repo.delete.return_value = False uc = DeleteTemplateUseCase(repo) result = uc.execute("t999", "u1") assert result is False # ── CopyTemplateUseCase ───────────────────────────────────────────────────── class TestCopyTemplateUseCase: def test_copy_success(self): copied = _make_template("t2", name="副本") repo = MagicMock() repo.get.return_value = _make_template("t1") repo.copy_template.return_value = copied uc = CopyTemplateUseCase(repo) cmd = CopyTemplateCommand(template_id="t1", user_id="u1", new_name="副本") result = uc.execute(cmd) assert result.name == "副本" repo.copy_template.assert_called_once_with("t1", "u1", "副本") def test_copy_not_found_raises(self): repo = MagicMock() repo.get.return_value = None uc = CopyTemplateUseCase(repo) cmd = CopyTemplateCommand(template_id="t999", user_id="u1", new_name="副本") with pytest.raises(NotFoundError): uc.execute(cmd) def test_copy_empty_name_raises(self): repo = MagicMock() repo.get.return_value = _make_template() uc = CopyTemplateUseCase(repo) cmd = CopyTemplateCommand(template_id="t1", user_id="u1", new_name="") with pytest.raises(ValidationError, match="新模板名称不能为空"): uc.execute(cmd) def test_copy_whitespace_name_raises(self): repo = MagicMock() repo.get.return_value = _make_template() uc = CopyTemplateUseCase(repo) cmd = CopyTemplateCommand(template_id="t1", user_id="u1", new_name=" ") with pytest.raises(ValidationError, match="新模板名称不能为空"): uc.execute(cmd) def test_copy_name_stripped(self): copied = _make_template("t2", name="副本") repo = MagicMock() repo.get.return_value = _make_template("t1") repo.copy_template.return_value = copied uc = CopyTemplateUseCase(repo) cmd = CopyTemplateCommand(template_id="t1", user_id="u1", new_name=" 副本 ") result = uc.execute(cmd) # 会被strip后传给repository repo.copy_template.assert_called_once_with("t1", "u1", "副本") # ── ValidateTemplateUseCase ───────────────────────────────────────────────── class TestValidateTemplateUseCase: def test_one_take_one_segment_ok(self): template = _make_template(mode=EditingMode.ONE_TAKE.value, segments=_make_segments(1)) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") result = uc.execute(cmd) assert isinstance(result, ValidateResult) assert result.template.id == "t1" assert len(result.warnings) == 0 def test_one_take_zero_segments_raises(self): template = _make_template(mode=EditingMode.ONE_TAKE.value, segments=[]) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") with pytest.raises(ValidationError, match="一镜到底模式必须恰好有 1 个片段"): uc.execute(cmd) def test_one_take_multiple_segments_raises(self): template = _make_template(mode=EditingMode.ONE_TAKE.value, segments=_make_segments(3)) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") with pytest.raises(ValidationError): uc.execute(cmd) def test_voice_over_valid_material_types(self): segments = [ TemplateSegment( id="s1", template_id="t1", segment_order=1, duration_min=3, duration_max=5, material_type="人物" ), TemplateSegment( id="s2", template_id="t1", segment_order=2, duration_min=5, duration_max=8, material_type="场景" ), ] template = _make_template(mode=EditingMode.VOICE_OVER.value, segments=segments) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") result = uc.execute(cmd) assert len(result.warnings) == 0 def test_voice_over_missing_material_type_raises(self): segments = [ TemplateSegment( id="s1", template_id="t1", segment_order=1, duration_min=3, duration_max=5, material_type=None ), ] template = _make_template(mode=EditingMode.VOICE_OVER.value, segments=segments) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") with pytest.raises(ValidationError, match="material_type"): uc.execute(cmd) def test_voice_over_invalid_material_type_raises(self): segments = [ TemplateSegment( id="s1", template_id="t1", segment_order=1, duration_min=3, duration_max=5, material_type="动物" ), ] template = _make_template(mode=EditingMode.VOICE_OVER.value, segments=segments) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") with pytest.raises(ValidationError, match="material_type"): uc.execute(cmd) def test_voice_over_second_segment_invalid(self): segments = [ TemplateSegment( id="s1", template_id="t1", segment_order=1, duration_min=3, duration_max=5, material_type="人物" ), TemplateSegment( id="s2", template_id="t1", segment_order=2, duration_min=5, duration_max=8, material_type="bad" ), ] template = _make_template(mode=EditingMode.VOICE_OVER.value, segments=segments) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") with pytest.raises(ValidationError) as exc_info: uc.execute(cmd) # 报错应该提到片段2 assert "2" in str(exc_info.value) def test_voice_duration_mismatch_warning(self): template = _make_template( mode=EditingMode.VOICE_OVER.value, estimated_duration=100.0, segments=_make_segments(2, material_type="人物"), ) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) # 配音时长只有50s,预估100s,偏差50% > 30% cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=50.0) result = uc.execute(cmd) assert len(result.warnings) == 1 w = result.warnings[0] assert isinstance(w, GenerateWarning) assert w.code == "voiceover_duration_mismatch" assert "偏差超过" in w.message def test_voice_duration_match_no_warning(self): template = _make_template( mode=EditingMode.VOICE_OVER.value, estimated_duration=100.0, segments=_make_segments(1, material_type="人物"), ) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) # 配音时长95s,预估100s,偏差5% < 30% cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=95.0) result = uc.execute(cmd) assert len(result.warnings) == 0 def test_voice_duration_at_30_percent_boundary_lower(self): # 恰好 0.7 边界不触发 template = _make_template(mode=EditingMode.PIP.value, estimated_duration=100.0) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=70.0) result = uc.execute(cmd) # 恰好 0.7,不算 < 0.7,应该不触发 assert len(result.warnings) == 0 def test_voice_duration_below_70_percent_triggers(self): template = _make_template(mode=EditingMode.PIP.value, estimated_duration=100.0) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=69.0) result = uc.execute(cmd) assert len(result.warnings) == 1 def test_voice_duration_above_130_percent_triggers(self): template = _make_template(mode=EditingMode.PIP.value, estimated_duration=100.0) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=131.0) result = uc.execute(cmd) assert len(result.warnings) == 1 def test_voice_duration_zero_estimated_skip(self): # estimated_duration = 0 不会做比例计算 template = _make_template(estimated_duration=0.0) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=10.0) result = uc.execute(cmd) assert len(result.warnings) == 0 def test_voiceover_duration_none_no_warning(self): template = _make_template() repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1") result = uc.execute(cmd) assert len(result.warnings) == 0 def test_validate_not_found_raises(self): repo = MagicMock() repo.get.return_value = None uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t999", user_id="u1") with pytest.raises(NotFoundError): uc.execute(cmd) def test_warning_details_structure(self): template = _make_template(estimated_duration=100.0) repo = MagicMock() repo.get.return_value = template uc = ValidateTemplateUseCase(repo) cmd = ValidateTemplateCommand(template_id="t1", user_id="u1", voiceover_duration=200.0) result = uc.execute(cmd) assert len(result.warnings) == 1 details = result.warnings[0].details assert "voiceover_duration" in details assert "estimated_duration" in details assert "ratio" in details assert details["voiceover_duration"] == 200.0 assert details["estimated_duration"] == 100.0 assert details["ratio"] == 2.0 # ── Category Use Cases ────────────────────────────────────────────────────── class TestCategoryUseCases: def test_create_category(self): cat = TemplateCategory(id="c1", user_id="u1", name="分类A") repo = MagicMock() repo.create_category.return_value = cat uc = CreateCategoryUseCase(repo) cmd = CreateCategoryCommand(user_id="u1", name="分类A") result = uc.execute(cmd) assert result.name == "分类A" repo.create_category.assert_called_once() def test_list_categories(self): cats = [TemplateCategory(id="c1", user_id="u1", name="A"), TemplateCategory(id="c2", user_id="u1", name="B")] repo = MagicMock() repo.list_categories.return_value = cats uc = ListCategoriesUseCase(repo) result = uc.execute("u1") assert len(result) == 2 repo.list_categories.assert_called_once_with("u1") def test_delete_category(self): repo = MagicMock() repo.delete_category.return_value = True uc = DeleteCategoryUseCase(repo) result = uc.execute("c1", "u1") assert result is True repo.delete_category.assert_called_once_with("c1", "u1") # ── Tags Use Case ─────────────────────────────────────────────────────────── class TestListTagsUseCase: def test_list_tags(self): repo = MagicMock() repo.list_tags.return_value = ["tag1", "tag2", "tag3"] uc = ListTagsUseCase(repo) result = uc.execute("u1") assert result == ["tag1", "tag2", "tag3"] repo.list_tags.assert_called_once_with("u1") # ── Usage Stats Use Case ──────────────────────────────────────────────────── class TestGetTemplateUsageUseCase: def test_get_usage(self): repo = MagicMock() repo.get_usage_count.return_value = 5 uc = GetTemplateUsageUseCase(repo) result = uc.execute("t1") assert result == 5 repo.get_usage_count.assert_called_once_with("t1") def test_get_usage_zero(self): repo = MagicMock() repo.get_usage_count.return_value = 0 uc = GetTemplateUsageUseCase(repo) result = uc.execute("t999") assert result == 0