diff --git a/tests/unit/test_asset_libraries_use_case.py b/tests/unit/test_asset_libraries_use_case.py new file mode 100755 index 000000000..6493a5061 --- /dev/null +++ b/tests/unit/test_asset_libraries_use_case.py @@ -0,0 +1,157 @@ +"""素材库 UseCase 单元测试.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from packages.application.asset_libraries import ( + CreateAssetLibraryCommand, + CreateAssetLibraryUseCase, + ListAssetLibrariesUseCase, +) +from packages.domain import AssetLibrary, AssetLibraryKind + + +@pytest.fixture +def mock_repo(): + repo = MagicMock() + return repo + + +@pytest.fixture +def sample_library(): + lib = AssetLibrary.create( + project_id="proj_456", + name="测试视频库", + kind=AssetLibraryKind.VIDEO, + ) + lib.id = "lib_123" + return lib + + +class TestListAssetLibrariesUseCase: + """ListAssetLibrariesUseCase 测试""" + + def test_list_returns_repo_results(self, mock_repo, sample_library): + """正常返回 repository 的查询结果""" + mock_repo.find_by_project.return_value = [sample_library] + use_case = ListAssetLibrariesUseCase(mock_repo) + + result = use_case.execute("proj_456") + + assert len(result) == 1 + assert result[0].id == "lib_123" + mock_repo.find_by_project.assert_called_once_with("proj_456") + + def test_empty_project_raises_value_error(self, mock_repo): + """空 project_id 抛出 ValueError""" + use_case = ListAssetLibrariesUseCase(mock_repo) + + with pytest.raises(ValueError, match="project_id 不能为空"): + use_case.execute("") + + mock_repo.find_by_project.assert_not_called() + + def test_whitespace_project_raises_value_error(self, mock_repo): + """纯空格 project_id 也抛出 ValueError""" + use_case = ListAssetLibrariesUseCase(mock_repo) + + with pytest.raises(ValueError, match="project_id 不能为空"): + use_case.execute(" ") + + mock_repo.find_by_project.assert_not_called() + + def test_project_id_stripped_before_query(self, mock_repo, sample_library): + """project_id 会被 strip 后再查询""" + mock_repo.find_by_project.return_value = [sample_library] + use_case = ListAssetLibrariesUseCase(mock_repo) + + use_case.execute(" proj_456 ") + + mock_repo.find_by_project.assert_called_once_with("proj_456") + + def test_empty_list(self, mock_repo): + """项目没有素材库时返回空列表""" + mock_repo.find_by_project.return_value = [] + use_case = ListAssetLibrariesUseCase(mock_repo) + + result = use_case.execute("proj_456") + + assert result == [] + + +class TestCreateAssetLibraryUseCase: + """CreateAssetLibraryUseCase 测试""" + + def test_create_success(self, mock_repo, sample_library): + """创建成功返回 AssetLibrary""" + mock_repo.create.return_value = sample_library + use_case = CreateAssetLibraryUseCase(mock_repo) + + command = CreateAssetLibraryCommand( + project_id="proj_456", + name="新素材库", + kind=AssetLibraryKind.IMAGE, + ) + result = use_case.execute(command) + + assert result.id == "lib_123" + mock_repo.create.assert_called_once() + # 验证传入 repository 的是一个 AssetLibrary 对象 + created = mock_repo.create.call_args[0][0] + assert isinstance(created, AssetLibrary) + assert created.project_id == "proj_456" + assert created.name == "新素材库" + assert created.kind == AssetLibraryKind.IMAGE + + def test_create_with_video_kind(self, mock_repo): + """创建视频类型素材库""" + mock_repo.create.side_effect = lambda x: x + use_case = CreateAssetLibraryUseCase(mock_repo) + + command = CreateAssetLibraryCommand( + project_id="proj_1", + name="视频库", + kind=AssetLibraryKind.VIDEO, + ) + result = use_case.execute(command) + + assert result.kind == AssetLibraryKind.VIDEO + assert result.name == "视频库" + + def test_create_with_voice_kind(self, mock_repo): + """创建音色类型素材库""" + mock_repo.create.side_effect = lambda x: x + use_case = CreateAssetLibraryUseCase(mock_repo) + + command = CreateAssetLibraryCommand( + project_id="proj_1", + name="音色库", + kind=AssetLibraryKind.VOICE, + ) + result = use_case.execute(command) + + assert result.kind == AssetLibraryKind.VOICE + + +class TestCreateAssetLibraryCommand: + """CreateAssetLibraryCommand 数据类测试""" + + def test_command_fields(self): + """命令对象字段正确""" + cmd = CreateAssetLibraryCommand( + project_id="proj_1", + name="test", + kind=AssetLibraryKind.VIDEO, + ) + assert cmd.project_id == "proj_1" + assert cmd.name == "test" + assert cmd.kind == AssetLibraryKind.VIDEO + + def test_command_is_dataclass(self): + """命令是 dataclass""" + from dataclasses import is_dataclass + + assert is_dataclass(CreateAssetLibraryCommand) diff --git a/tests/unit/test_bgm_utils.py b/tests/unit/test_bgm_utils.py new file mode 100755 index 000000000..523a8331a --- /dev/null +++ b/tests/unit/test_bgm_utils.py @@ -0,0 +1,105 @@ +"""BGM 配置工具函数单元测试.""" + +import pytest + +from packages.domain.bgm_utils import merge_bgm_config + + +class TestMergeBgmConfig: + """merge_bgm_config 测试""" + + def test_user_bgm_empty_returns_template_copy(self): + """用户配置为空时,返回模板配置的拷贝""" + template = {"enabled": True, "volume": 0.5, "asset_id": "tpl_123"} + result = merge_bgm_config(template, {}) + assert result == template + assert result is not template + + def test_user_bgm_none_returns_template_copy(self): + """用户配置为 None 时,返回模板配置的拷贝""" + template = {"enabled": True, "volume": 0.5} + result = merge_bgm_config(template, None) # type: ignore + assert result == template + + def test_template_bgm_empty_returns_user_copy(self): + """模板配置为空时,返回用户配置的拷贝""" + user = {"enabled": False, "volume": 0.8, "asset_id": "user_456"} + result = merge_bgm_config({}, user) + assert result == user + assert result is not user + + def test_template_bgm_none_returns_user_copy(self): + """模板配置为 None 时,返回用户配置的拷贝""" + user = {"enabled": False, "volume": 0.8} + result = merge_bgm_config(None, user) # type: ignore + assert result == user + + def test_user_fields_override_template(self): + """用户显式指定的字段覆盖模板对应字段""" + template = { + "enabled": True, + "volume": 0.5, + "asset_id": "tpl_123", + "fade_in": 1.0, + } + user = { + "volume": 0.8, + "asset_id": "user_456", + } + result = merge_bgm_config(template, user) + assert result["volume"] == 0.8 + assert result["asset_id"] == "user_456" + assert result["fade_in"] == 1.0 # 模板值保留 + + def test_enabled_not_in_user_preserves_template_enabled(self): + """enabled 特殊处理:用户没传 enabled 时保留模板的 enabled 值""" + template = {"enabled": True, "volume": 0.5} + user = {"volume": 0.8} # 没传 enabled + result = merge_bgm_config(template, user) + assert result["enabled"] is True # 保留模板的 + assert result["volume"] == 0.8 # 用户指定的覆盖 + + def test_enabled_in_user_overrides_template(self): + """用户传了 enabled 时覆盖模板的 enabled""" + template = {"enabled": True, "volume": 0.5} + user = {"enabled": False, "volume": 0.8} + result = merge_bgm_config(template, user) + assert result["enabled"] is False + assert result["volume"] == 0.8 + + def test_user_adds_new_fields(self): + """用户配置中的新字段会被添加到结果中""" + template = {"enabled": True, "volume": 0.5} + user = {"sidechain_enabled": True, "sidechain_ratio": 0.6} + result = merge_bgm_config(template, user) + assert result["enabled"] is True + assert result["volume"] == 0.5 + assert result["sidechain_enabled"] is True + assert result["sidechain_ratio"] == 0.6 + + def test_both_empty_returns_empty_dict(self): + """两者都为空时返回空字典""" + result = merge_bgm_config({}, {}) + assert result == {} + + def test_nested_dict_shallow_merge(self): + """嵌套字典是浅合并(当前设计)""" + template = {"enabled": True, "config": {"eq": True, "compression": False}} + user = {"config": {"compression": True, "reverb": 0.5}} + result = merge_bgm_config(template, user) + # 浅合并:整个 config 被用户值覆盖 + assert result["config"] == {"compression": True, "reverb": 0.5} + + def test_does_not_mutate_template(self): + """不修改原始模板配置""" + template = {"enabled": True, "volume": 0.5} + original = dict(template) + merge_bgm_config(template, {"volume": 0.8}) + assert template == original + + def test_does_not_mutate_user(self): + """不修改原始用户配置""" + user = {"volume": 0.8} + original = dict(user) + merge_bgm_config({"enabled": True}, user) + assert user == original diff --git a/tests/unit/test_domain_exceptions.py b/tests/unit/test_domain_exceptions.py new file mode 100755 index 000000000..7318a4df9 --- /dev/null +++ b/tests/unit/test_domain_exceptions.py @@ -0,0 +1,131 @@ +"""领域层通用异常单元测试.""" + +import pytest + +from packages.domain.exceptions import ( + DomainError, + NotFoundError, + QuotaExceededError, + ValidationError, +) + + +class TestDomainError: + """DomainError 基类测试""" + + def test_is_exception(self): + """DomainError 是 Exception 的子类""" + assert issubclass(DomainError, Exception) + + def test_can_raise_and_catch(self): + """可以抛出和捕获""" + with pytest.raises(DomainError): + raise DomainError("something went wrong") + + def test_message(self): + """异常消息正确""" + err = DomainError("test message") + assert str(err) == "test message" + + +class TestNotFoundError: + """NotFoundError 测试""" + + def test_is_domain_error(self): + """NotFoundError 继承自 DomainError""" + assert issubclass(NotFoundError, DomainError) + + def test_can_raise_as_domain_error(self): + """可以作为 DomainError 捕获""" + with pytest.raises(DomainError): + raise NotFoundError("resource not found") + + def test_default_message(self): + """无参构造""" + err = NotFoundError() + assert isinstance(err, NotFoundError) + + def test_custom_message(self): + """自定义消息""" + err = NotFoundError("user 123 not found") + assert str(err) == "user 123 not found" + + +class TestValidationError: + """ValidationError 测试""" + + def test_is_domain_error(self): + """ValidationError 继承自 DomainError""" + assert issubclass(ValidationError, DomainError) + + def test_can_raise_as_domain_error(self): + """可以作为 DomainError 捕获""" + with pytest.raises(DomainError): + raise ValidationError("invalid input") + + def test_custom_message(self): + """自定义消息""" + err = ValidationError("duration must be positive") + assert str(err) == "duration must be positive" + + +class TestQuotaExceededError: + """QuotaExceededError 测试""" + + def test_is_domain_error(self): + """QuotaExceededError 继承自 DomainError""" + assert issubclass(QuotaExceededError, DomainError) + + def test_can_raise_as_domain_error(self): + """可以作为 DomainError 捕获""" + with pytest.raises(DomainError): + raise QuotaExceededError("storage", 1024.0, 2048.0) + + def test_stores_dimension_limit_used(self): + """保存 dimension、limit、used 属性""" + err = QuotaExceededError("storage_mb", 1024.0, 1500.0) + assert err.dimension == "storage_mb" + assert err.limit == 1024.0 + assert err.used == 1500.0 + + def test_error_message_format(self): + """异常消息格式正确""" + err = QuotaExceededError("storage_mb", 1024.0, 1500.0) + msg = str(err) + assert "storage_mb" in msg + assert "1500.0" in msg + assert "1024.0" in msg + assert "Quota exceeded" in msg + + def test_integer_values(self): + """整数值也能正常工作""" + err = QuotaExceededError("projects", 10, 15) + assert err.dimension == "projects" + assert err.limit == 10 + assert err.used == 15 + + def test_zero_limit(self): + """限制为 0 时也能正常工作""" + err = QuotaExceededError("custom_templates", 0, 1) + assert err.limit == 0 + assert err.used == 1 + + +class TestExceptionHierarchy: + """异常继承关系测试""" + + def test_all_are_domain_errors(self): + """所有异常都可以作为 DomainError 捕获""" + errors = [ + NotFoundError(), + ValidationError("bad"), + QuotaExceededError("x", 10.0, 20.0), + ] + for err in errors: + assert isinstance(err, DomainError) + + def test_distinct_types(self): + """不同异常类型可以区分""" + assert not issubclass(NotFoundError, ValidationError) + assert not issubclass(ValidationError, QuotaExceededError) + assert not issubclass(NotFoundError, QuotaExceededError)