diff --git a/tests/unit/test_small_domain_modules.py b/tests/unit/test_small_domain_modules.py new file mode 100644 index 000000000..e4516e39e --- /dev/null +++ b/tests/unit/test_small_domain_modules.py @@ -0,0 +1,377 @@ +"""小领域模块组合单测 — template_clip_config / tag / exceptions / editing_mode.""" + +from __future__ import annotations + +import pytest + +from packages.domain.editing_mode import EditingMode +from packages.domain.exceptions import ( + DomainError, + NotFoundError, + QuotaExceededError, + ValidationError, +) +from packages.domain.tag import Tag +from packages.domain.template_clip_config import ( + ClipType, + TemplateClipConfig, + TransitionEffect, +) + + +# ── EditingMode ──────────────────────────────────────────────────────────── + + +class TestEditingMode: + def test_one_take(self): + assert EditingMode.ONE_TAKE == "one_take" + + def test_pip(self): + assert EditingMode.PIP == "pip" + + def test_voice_over(self): + assert EditingMode.VOICE_OVER == "voice_over" + + def test_voice_pip(self): + assert EditingMode.VOICE_PIP == "voice_pip" + + def test_from_string(self): + assert EditingMode("one_take") == EditingMode.ONE_TAKE + assert EditingMode("pip") == EditingMode.PIP + + def test_invalid_raises(self): + with pytest.raises(ValueError): + EditingMode("invalid_mode") + + def test_is_str(self): + # StrEnum 实例本身就是 str + assert isinstance(EditingMode.ONE_TAKE, str) + assert EditingMode.ONE_TAKE + "_suffix" == "one_take_suffix" + + +# ── 异常类 ───────────────────────────────────────────────────────────────── + + +class TestExceptions: + def test_domain_error_is_exception(self): + err = DomainError("test") + assert isinstance(err, Exception) + assert str(err) == "test" + + def test_not_found_inherits_domain(self): + err = NotFoundError("not found") + assert isinstance(err, DomainError) + assert str(err) == "not found" + + def test_validation_error_inherits_domain(self): + err = ValidationError("bad input") + assert isinstance(err, DomainError) + assert str(err) == "bad input" + + def test_quota_exceeded_error_attributes(self): + err = QuotaExceededError(dimension="storage_gb", limit=100, used=150) + assert err.dimension == "storage_gb" + assert err.limit == 100 + assert err.used == 150 + assert "storage_gb" in str(err) + assert "150" in str(err) + assert "100" in str(err) + + def test_quota_exceeded_is_domain_error(self): + err = QuotaExceededError("x", 10, 20) + assert isinstance(err, DomainError) + + def test_catch_domain_error_catches_all(self): + """所有领域异常都能被 DomainError catch.""" + for cls in [NotFoundError, ValidationError, QuotaExceededError]: + try: + if cls == QuotaExceededError: + raise cls("dim", 10, 20) + raise cls("msg") + except DomainError: + pass + else: + pytest.fail(f"{cls.__name__} 未被 DomainError 捕获") + + +# ── Tag ──────────────────────────────────────────────────────────────────── + + +class TestTag: + def test_create_basic(self): + tag = Tag.create(user_id="user1", name="旅行") + assert tag.id + assert tag.user_id == "user1" + assert tag.name == "旅行" + + def test_create_name_stripped(self): + tag = Tag.create(user_id="u1", name=" 美食 ") + assert tag.name == "美食" + + def test_create_empty_name_raises(self): + with pytest.raises(ValueError, match="标签名称不能为空"): + Tag.create(user_id="u1", name="") + + def test_create_whitespace_name_raises(self): + with pytest.raises(ValueError, match="标签名称不能为空"): + Tag.create(user_id="u1", name=" ") + + def test_create_has_created_at(self): + tag = Tag.create(user_id="u1", name="t1") + assert tag.created_at is not None + + def test_unique_ids(self): + t1 = Tag.create(user_id="u1", name="t1") + t2 = Tag.create(user_id="u1", name="t2") + assert t1.id != t2.id + + +# ── ClipType 枚举 ───────────────────────────────────────────────────────── + + +class TestClipType: + def test_all_types(self): + assert ClipType.INTRO == "intro" + assert ClipType.MAIN == "main" + assert ClipType.TRANSITION == "transition" + assert ClipType.OUTRO == "outro" + assert ClipType.TITLE == "title" + assert ClipType.SUBTITLE == "subtitle" + + def test_from_string(self): + assert ClipType("main") == ClipType.MAIN + assert ClipType("intro") == ClipType.INTRO + + def test_invalid_raises(self): + with pytest.raises(ValueError): + ClipType("invalid") + + +# ── TransitionEffect 枚举 ───────────────────────────────────────────────── + + +class TestTransitionEffect: + def test_all_effects(self): + assert TransitionEffect.CUT == "cut" + assert TransitionEffect.FADE == "fade" + assert TransitionEffect.SLIDE_LEFT == "slide_left" + assert TransitionEffect.SLIDE_RIGHT == "slide_right" + assert TransitionEffect.DISSOLVE == "dissolve" + assert TransitionEffect.WIPE == "wipe" + + def test_from_string(self): + assert TransitionEffect("fade") == TransitionEffect.FADE + assert TransitionEffect("cut") == TransitionEffect.CUT + + def test_invalid_raises(self): + with pytest.raises(ValueError): + TransitionEffect("invalid_effect") + + +# ── TemplateClipConfig.create ────────────────────────────────────────────── + + +class TestTemplateClipConfigCreate: + def test_basic_creation(self): + clip = TemplateClipConfig.create( + template_id="tmpl_1", + clip_type=ClipType.MAIN, + order=1, + ) + assert clip.id + assert clip.template_id == "tmpl_1" + assert clip.clip_type == ClipType.MAIN + assert clip.order == 1 + assert clip.min_duration == 0.0 + assert clip.max_duration == 0.0 + assert clip.text_template == "" + assert clip.material_requirements == {} + assert clip.transition_effect == TransitionEffect.CUT + assert clip.config == {} + + def test_clip_type_string(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type="intro", order=0, + ) + assert clip.clip_type == ClipType.INTRO + + def test_template_id_stripped(self): + clip = TemplateClipConfig.create( + template_id=" tmpl_1 ", clip_type=ClipType.MAIN, order=1, + ) + assert clip.template_id == "tmpl_1" + + def test_empty_template_id_raises(self): + with pytest.raises(ValueError, match="template_id 不能为空"): + TemplateClipConfig.create(template_id="", clip_type=ClipType.MAIN, order=1) + + def test_whitespace_template_id_raises(self): + with pytest.raises(ValueError, match="template_id 不能为空"): + TemplateClipConfig.create(template_id=" ", clip_type=ClipType.MAIN, order=1) + + def test_negative_min_duration_raises(self): + with pytest.raises(ValueError, match="min_duration 不能为负数"): + TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=-1.0, + ) + + def test_negative_max_duration_raises(self): + with pytest.raises(ValueError, match="max_duration 不能为负数"): + TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + max_duration=-1.0, + ) + + def test_min_greater_than_max_raises(self): + with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"): + TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=10.0, max_duration=5.0, + ) + + def test_zero_min_and_max_ok(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=0, max_duration=0, + ) + assert clip.min_duration == 0 + assert clip.max_duration == 0 + + def test_min_zero_max_positive_ok(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=0, max_duration=10.0, + ) + assert clip.max_duration == 10.0 + + def test_min_equals_max_ok(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=5.0, max_duration=5.0, + ) + assert clip.min_duration == 5.0 + assert clip.max_duration == 5.0 + + def test_with_text_template(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + text_template=" 欢迎收看 {channel} ", + ) + # text_template 会 strip + assert clip.text_template == "欢迎收看 {channel}" + + def test_with_material_requirements(self): + reqs = {"material_type": "video", "min_duration": 3} + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + material_requirements=reqs, + ) + assert clip.material_requirements == reqs + + def test_material_requirements_none_defaults_empty(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + material_requirements=None, + ) + assert clip.material_requirements == {} + + def test_transition_effect_string(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + transition_effect="fade", + ) + assert clip.transition_effect == TransitionEffect.FADE + + def test_with_config(self): + config = {"speed": 1.5, "filter": "vibrance"} + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + config=config, + ) + assert clip.config == config + + def test_config_none_defaults_empty(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + config=None, + ) + assert clip.config == {} + + def test_created_at_and_updated_at(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + ) + assert clip.created_at is not None + assert clip.updated_at is not None + + def test_unique_ids(self): + c1 = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=1) + c2 = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=2) + assert c1.id != c2.id + + +# ── TemplateClipConfig 属性 ──────────────────────────────────────────────── + + +class TestTemplateClipConfigProperties: + def test_has_duration_range_false_both_zero(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + ) + assert clip.has_duration_range is False + + def test_has_duration_range_true_min_only(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=2.0, + ) + assert clip.has_duration_range is True + + def test_has_duration_range_true_max_only(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + max_duration=10.0, + ) + assert clip.has_duration_range is True + + def test_has_duration_range_true_both_set(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=2.0, max_duration=10.0, + ) + assert clip.has_duration_range is True + + def test_default_duration_zero(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + ) + assert clip.default_duration == 0.0 + + def test_default_duration_min_only(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=3.0, + ) + assert clip.default_duration == 3.0 + + def test_default_duration_max_only(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + max_duration=10.0, + ) + assert clip.default_duration == 10.0 + + def test_default_duration_both_midpoint(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=2.0, max_duration=8.0, + ) + assert clip.default_duration == 5.0 + + def test_default_duration_min_equals_max(self): + clip = TemplateClipConfig.create( + template_id="t1", clip_type=ClipType.MAIN, order=1, + min_duration=5.0, max_duration=5.0, + ) + assert clip.default_duration == 5.0