"""VoiceCloneProfile 领域层单元测试 - voice_clone_profile.py""" import pytest from packages.domain.voice_clone_profile import ( TERMINAL_STATUSES, VoiceCloneProfile, VoiceCloneStatus, ) class TestVoiceCloneStatus: """VoiceCloneStatus 枚举测试""" def test_all_statuses_have_values(self): for s in VoiceCloneStatus: assert isinstance(s.value, str) assert s.value def test_str_enum(self): assert VoiceCloneStatus.PENDING == "pending" assert isinstance(VoiceCloneStatus.PENDING, str) def test_terminal_statuses(self): assert VoiceCloneStatus.READY in TERMINAL_STATUSES assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES def test_non_terminal(self): assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES class TestVoiceCloneProfileCreate: """create 工厂方法测试""" def test_create_basic(self): profile = VoiceCloneProfile.create(user_id="user-1", name="我的音色") assert profile.id assert len(profile.id) == 32 assert profile.user_id == "user-1" assert profile.name == "我的音色" assert profile.status == VoiceCloneStatus.PENDING assert profile.description == "" assert profile.source_audio_url == "" assert profile.voice_id == "" assert profile.language == "zh-CN" assert profile.gender == "unknown" assert profile.retry_count == 0 assert profile.max_retries == 3 assert profile.metadata == {} def test_create_empty_user_id_raises(self): with pytest.raises(ValueError, match="user_id 不能为空"): VoiceCloneProfile.create(user_id=" ", name="test") def test_create_empty_name_raises(self): with pytest.raises(ValueError, match="name 不能为空"): VoiceCloneProfile.create(user_id="u1", name=" ") def test_create_name_too_long_raises(self): long_name = "a" * 101 with pytest.raises(ValueError, match="name 长度不能超过 100"): VoiceCloneProfile.create(user_id="u1", name=long_name) def test_create_name_exactly_100_ok(self): name = "a" * 100 profile = VoiceCloneProfile.create(user_id="u1", name=name) assert profile.name == name def test_create_with_description(self): profile = VoiceCloneProfile.create(user_id="u1", name="test", description="温暖男声") assert profile.description == "温暖男声" def test_create_with_source_audio(self): profile = VoiceCloneProfile.create(user_id="u1", name="test", source_audio_url="http://audio.com/source.wav") assert profile.source_audio_url == "http://audio.com/source.wav" def test_create_with_language(self): profile = VoiceCloneProfile.create(user_id="u1", name="test", language="en-US") assert profile.language == "en-US" def test_create_gender_lowercased(self): profile = VoiceCloneProfile.create(user_id="u1", name="test", gender="MALE") assert profile.gender == "male" def test_create_with_metadata(self): meta = {"source": "upload", "duration": 30} profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata=meta) assert profile.metadata == meta def test_create_none_metadata_defaults_empty(self): profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata=None) assert profile.metadata == {} def test_create_fields_stripped(self): profile = VoiceCloneProfile.create( user_id=" u1 ", name=" test ", description=" desc ", source_audio_url=" url ", voice_model=" model ", language=" zh-CN ", ) assert profile.user_id == "u1" assert profile.name == "test" assert profile.description == "desc" assert profile.source_audio_url == "url" assert profile.voice_model == "model" assert profile.language == "zh-CN" def test_create_custom_max_retries(self): profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=5) assert profile.max_retries == 5 class TestVoiceCloneProfileProperties: """属性测试""" def test_is_terminal_pending(self): p = VoiceCloneProfile.create(user_id="u1", name="test") assert p.is_terminal is False def test_is_terminal_ready(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_ready("voice-123") assert p.is_terminal is True def test_is_terminal_failed(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_failed("error") assert p.is_terminal is True def test_is_terminal_disabled(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_disabled() assert p.is_terminal is True def test_is_retryable_failed_within_limit(self): p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3) p.mark_processing() p.mark_failed("error") assert p.is_retryable is True def test_is_retryable_failed_at_limit(self): p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=1) p.mark_processing() p.mark_failed("error") p.retry_count = 1 assert p.is_retryable is False def test_is_ready_true(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_ready("voice-123") assert p.is_ready is True def test_is_ready_no_voice_id(self): """ready 状态但没有 voice_id,is_ready 为 False""" p = VoiceCloneProfile.create(user_id="u1", name="test") p.status = VoiceCloneStatus.READY p.voice_id = "" assert p.is_ready is False def test_is_ready_not_ready_status(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.voice_id = "some-id" assert p.is_ready is False # 状态是 PENDING class TestVoiceCloneProfileTransitions: """状态转换测试""" def test_pending_to_processing(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.PROCESSING) assert p.status == VoiceCloneStatus.PROCESSING def test_pending_to_failed(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.FAILED) assert p.status == VoiceCloneStatus.FAILED def test_pending_to_disabled(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_processing_to_ready(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.READY) assert p.status == VoiceCloneStatus.READY def test_processing_to_failed(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.FAILED) assert p.status == VoiceCloneStatus.FAILED def test_processing_to_disabled(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_ready_to_disabled(self): """已就绪音色可以被禁用""" p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_ready("voice-1") p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_failed_to_pending_retry(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.FAILED) p.transition_to(VoiceCloneStatus.PENDING) assert p.status == VoiceCloneStatus.PENDING def test_invalid_transition_raises(self): p = VoiceCloneProfile.create(user_id="u1", name="test") with pytest.raises(ValueError, match="非法状态转换"): p.transition_to(VoiceCloneStatus.READY) # pending 不能直接到 ready def test_disabled_to_pending_raises(self): """禁用后不能回到 pending""" p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_disabled() with pytest.raises(ValueError, match="非法状态转换"): p.transition_to(VoiceCloneStatus.PENDING) def test_transition_with_string(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.transition_to("processing") assert p.status == VoiceCloneStatus.PROCESSING def test_transition_invalid_string_raises(self): p = VoiceCloneProfile.create(user_id="u1", name="test") with pytest.raises(ValueError, match="无效状态"): p.transition_to("invalid") def test_transition_updates_updated_at(self): p = VoiceCloneProfile.create(user_id="u1", name="test") old = p.updated_at import time time.sleep(0.001) p.transition_to(VoiceCloneStatus.PROCESSING) assert p.updated_at >= old class TestVoiceCloneProfileMarkMethods: """便捷标记方法测试""" def test_mark_processing(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.error_message = "prev error" p.mark_processing() assert p.status == VoiceCloneStatus.PROCESSING assert p.error_message == "" def test_mark_ready(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_ready("voice-abc123") assert p.status == VoiceCloneStatus.READY assert p.voice_id == "voice-abc123" assert p.error_message == "" def test_mark_ready_empty_voice_id_raises(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() with pytest.raises(ValueError, match="voice_id 不能为空"): p.mark_ready(" ") def test_mark_failed(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_failed("音频质量太差") assert p.status == VoiceCloneStatus.FAILED assert p.error_message == "音频质量太差" def test_mark_disabled(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_disabled() assert p.status == VoiceCloneStatus.DISABLED class TestVoiceCloneProfileRetry: """重试逻辑测试""" def test_prepare_retry(self): p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3) p.mark_processing() p.mark_failed("超时") p.voice_id = "partial-id" p.prepare_retry() assert p.status == VoiceCloneStatus.PENDING assert p.retry_count == 1 assert p.error_message == "" assert p.voice_id == "" # 重试时清空 voice_id def test_prepare_retry_not_retryable_raises(self): p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=0) p.mark_processing() p.mark_failed("error") with pytest.raises(ValueError, match="不可重试"): p.prepare_retry() def test_multiple_retries(self): p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3) for i in range(3): p.mark_processing() p.mark_failed(f"error-{i}") p.prepare_retry() assert p.retry_count == i + 1 assert p.is_retryable is False class TestVoiceCloneProfileToDict: """to_dict 序列化测试""" def test_to_dict_contains_fields(self): p = VoiceCloneProfile.create( user_id="u1", name="我的音色", description="测试", language="en-US", gender="female", ) d = p.to_dict() assert d["id"] == p.id assert d["user_id"] == "u1" assert d["name"] == "我的音色" assert d["description"] == "测试" assert d["status"] == "pending" assert d["language"] == "en-US" assert d["gender"] == "female" assert d["is_retryable"] is False assert d["is_ready"] is False def test_to_dict_datetime_are_strings(self): p = VoiceCloneProfile.create(user_id="u1", name="test") d = p.to_dict() assert isinstance(d["created_at"], str) assert isinstance(d["updated_at"], str) def test_to_dict_after_ready(self): p = VoiceCloneProfile.create(user_id="u1", name="test") p.mark_processing() p.mark_ready("voice-123") d = p.to_dict() assert d["status"] == "ready" assert d["voice_id"] == "voice-123" assert d["is_ready"] is True