"""VoiceCloneProfile 音色克隆档案单测. 覆盖:状态枚举、create创建校验、状态机转换、标记方法、 重试逻辑、属性判断、to_dict序列化。 """ from __future__ import annotations from datetime import UTC, datetime, timezone from packages.domain.voice_clone_profile import ( TERMINAL_STATUSES, VoiceCloneProfile, VoiceCloneStatus, ) class TestVoiceCloneStatus: def test_status_values(self): assert VoiceCloneStatus.PENDING.value == "pending" assert VoiceCloneStatus.PROCESSING.value == "processing" assert VoiceCloneStatus.READY.value == "ready" assert VoiceCloneStatus.FAILED.value == "failed" assert VoiceCloneStatus.DISABLED.value == "disabled" def test_status_is_str_enum(self): assert isinstance(VoiceCloneStatus.PENDING, str) assert VoiceCloneStatus.PENDING == "pending" def test_terminal_statuses(self): assert VoiceCloneStatus.READY in TERMINAL_STATUSES assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES class TestVoiceCloneProfileCreate: def test_create_minimal(self): profile = VoiceCloneProfile.create(user_id="user_1", name="我的音色") assert profile.user_id == "user_1" assert profile.name == "我的音色" assert profile.status == VoiceCloneStatus.PENDING assert profile.id and len(profile.id) == 32 def test_create_with_all_params(self): profile = VoiceCloneProfile.create( user_id="user_1", name="甜美女声", description="适合播客的女声", source_audio_url="https://ex.com/source.wav", voice_model="cosyvoice-300m", language="en-US", gender="female", max_retries=5, metadata={"source": "upload"}, ) assert profile.description == "适合播客的女声" assert profile.source_audio_url == "https://ex.com/source.wav" assert profile.voice_model == "cosyvoice-300m" assert profile.language == "en-US" assert profile.gender == "female" assert profile.max_retries == 5 assert profile.metadata == {"source": "upload"} def test_create_defaults(self): profile = VoiceCloneProfile.create(user_id="u1", name="t") assert profile.description == "" assert profile.source_audio_url == "" assert profile.voice_model == "" assert profile.language == "zh-CN" assert profile.gender == "unknown" assert profile.max_retries == 3 assert profile.metadata == {} assert profile.voice_id == "" assert profile.error_message == "" assert profile.retry_count == 0 def test_create_strips_whitespace(self): profile = VoiceCloneProfile.create( user_id=" user_1 ", name=" 测试音色 ", description=" desc ", language=" en-US ", gender=" MALE ", ) assert profile.user_id == "user_1" assert profile.name == "测试音色" assert profile.description == "desc" assert profile.language == "en-US" assert profile.gender == "male" # lowercased def test_create_gender_lowercased(self): profile = VoiceCloneProfile.create(user_id="u1", name="t", gender="Female") assert profile.gender == "female" def test_create_empty_user_id_raises(self): try: VoiceCloneProfile.create(user_id="", name="t") except ValueError as e: assert "user_id" in str(e) else: raise AssertionError("expected ValueError") def test_create_whitespace_user_id_raises(self): try: VoiceCloneProfile.create(user_id=" ", name="t") except ValueError as e: assert "user_id" in str(e) else: raise AssertionError("expected ValueError") def test_create_empty_name_raises(self): try: VoiceCloneProfile.create(user_id="u1", name="") except ValueError as e: assert "name" in str(e) else: raise AssertionError("expected ValueError") def test_create_name_too_long_raises(self): long_name = "a" * 101 try: VoiceCloneProfile.create(user_id="u1", name=long_name) except ValueError as e: assert "100" in str(e) else: raise AssertionError("expected ValueError") 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_sets_created_at(self): before = datetime.now(UTC) profile = VoiceCloneProfile.create(user_id="u1", name="t") after = datetime.now(UTC) assert before <= profile.created_at <= after assert before <= profile.updated_at <= after def test_create_metadata_none_defaults_to_empty(self): profile = VoiceCloneProfile.create(user_id="u1", name="t", metadata=None) assert profile.metadata == {} class TestStatusProperties: def test_is_terminal_pending(self): p = VoiceCloneProfile.create(user_id="u", name="t") assert p.is_terminal is False def test_is_terminal_processing(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() assert p.is_terminal is False def test_is_terminal_ready(self): p = VoiceCloneProfile.create(user_id="u", name="t") 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="u", name="t") p.mark_processing() p.mark_failed("error") assert p.is_terminal is True def test_is_terminal_disabled_from_pending(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_disabled() assert p.is_terminal is True def test_is_retryable_failed_within_limit(self): p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=3) p.mark_processing() p.mark_failed("err") assert p.is_retryable is True def test_is_retryable_failed_at_limit(self): p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=1) p.mark_processing() p.mark_failed("err1") p.prepare_retry() p.mark_processing() p.mark_failed("err2") assert p.is_retryable is False def test_is_retryable_pending(self): p = VoiceCloneProfile.create(user_id="u", name="t") assert p.is_retryable is False def test_is_retryable_ready(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() p.mark_ready("v1") assert p.is_retryable is False def test_is_ready_success(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() p.mark_ready("voice_123") assert p.is_ready is True def test_is_ready_no_voice_id(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.status = VoiceCloneStatus.READY # 手动设状态,无voice_id p.voice_id = "" assert p.is_ready is False class TestTransitionTo: def test_pending_to_processing(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.PROCESSING) assert p.status == VoiceCloneStatus.PROCESSING def test_pending_to_failed(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.FAILED) assert p.status == VoiceCloneStatus.FAILED def test_pending_to_disabled(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_processing_to_ready(self): p = VoiceCloneProfile.create(user_id="u", name="t") 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="u", name="t") 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="u", name="t") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_failed_to_pending_retry(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.FAILED) p.transition_to(VoiceCloneStatus.PENDING) assert p.status == VoiceCloneStatus.PENDING def test_ready_to_disabled(self): """已就绪音色可以被禁用.""" p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.PROCESSING) p.transition_to(VoiceCloneStatus.READY) p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_invalid_transition_raises(self): p = VoiceCloneProfile.create(user_id="u", name="t") try: p.transition_to(VoiceCloneStatus.READY) # pending→ready 非法 except ValueError as e: assert "非法状态转换" in str(e) else: raise AssertionError("expected ValueError") def test_disabled_no_outgoing(self): """disabled状态不能转换到任何状态.""" p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.DISABLED) try: p.transition_to(VoiceCloneStatus.PENDING) except ValueError: pass else: raise AssertionError("expected ValueError") def test_transition_with_string(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to("processing") assert p.status == VoiceCloneStatus.PROCESSING def test_transition_invalid_string_raises(self): p = VoiceCloneProfile.create(user_id="u", name="t") try: p.transition_to("invalid_state") except ValueError as e: assert "无效状态" in str(e) else: raise AssertionError("expected ValueError") def test_transition_updates_updated_at(self): p = VoiceCloneProfile.create(user_id="u", name="t") old_updated = p.updated_at import time time.sleep(0.01) p.transition_to(VoiceCloneStatus.PROCESSING) assert p.updated_at > old_updated class TestMarkMethods: def test_mark_processing(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() assert p.status == VoiceCloneStatus.PROCESSING assert p.error_message == "" def test_mark_processing_clears_error(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.transition_to(VoiceCloneStatus.FAILED) p.error_message = "old error" p.retry_count = 1 p.transition_to(VoiceCloneStatus.PENDING) p.mark_processing() assert p.error_message == "" def test_mark_ready_success(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() p.mark_ready("voice_id_123") assert p.status == VoiceCloneStatus.READY assert p.voice_id == "voice_id_123" assert p.error_message == "" def test_mark_ready_strips_whitespace(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() p.mark_ready(" voice_456 ") assert p.voice_id == "voice_456" def test_mark_ready_empty_voice_id_raises(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() try: p.mark_ready("") except ValueError as e: assert "voice_id" in str(e) else: raise AssertionError("expected ValueError") def test_mark_ready_whitespace_voice_id_raises(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() try: p.mark_ready(" ") except ValueError as e: assert "voice_id" in str(e) else: raise AssertionError("expected ValueError") def test_mark_failed(self): p = VoiceCloneProfile.create(user_id="u", name="t") 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="u", name="t") p.mark_disabled() assert p.status == VoiceCloneStatus.DISABLED class TestRetry: def test_prepare_retry_success(self): p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=3) p.mark_processing() p.mark_failed("err") p.prepare_retry() assert p.status == VoiceCloneStatus.PENDING assert p.retry_count == 1 assert p.error_message == "" assert p.voice_id == "" def test_prepare_retry_multiple_times(self): p = VoiceCloneProfile.create(user_id="u", name="t", max_retries=3) for i in range(3): p.mark_processing() p.mark_failed(f"err_{i}") p.prepare_retry() assert p.retry_count == i + 1 assert p.status == VoiceCloneStatus.PENDING # 第4次应该失败 p.mark_processing() p.mark_failed("err_3") try: p.prepare_retry() except ValueError: pass else: raise AssertionError("expected ValueError on 4th retry") def test_prepare_retry_not_failed_raises(self): p = VoiceCloneProfile.create(user_id="u", name="t") try: p.prepare_retry() except ValueError as e: assert "不可重试" in str(e) else: raise AssertionError("expected ValueError") def test_prepare_retry_ready_raises(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() p.mark_ready("v1") try: p.prepare_retry() except ValueError as e: assert "不可重试" in str(e) else: raise AssertionError("expected ValueError") def test_prepare_retry_clears_voice_id(self): p = VoiceCloneProfile.create(user_id="u", name="t") p.mark_processing() p.mark_ready("partial_id") # 手动改到failed来测试 p.status = VoiceCloneStatus.FAILED p.retry_count = 0 p.prepare_retry() assert p.voice_id == "" class TestToDict: def test_to_dict_basic(self): p = VoiceCloneProfile.create(user_id="u1", name="测试音色") d = p.to_dict() assert d["id"] == p.id assert d["user_id"] == "u1" assert d["name"] == "测试音色" assert d["status"] == "pending" assert d["is_retryable"] is False assert d["is_ready"] is False def test_to_dict_ready(self): p = VoiceCloneProfile.create(user_id="u1", name="t") p.mark_processing() p.mark_ready("voice_42") d = p.to_dict() assert d["status"] == "ready" assert d["voice_id"] == "voice_42" assert d["is_ready"] is True assert d["created_at"] is not None assert d["updated_at"] is not None def test_to_dict_failed_retryable(self): p = VoiceCloneProfile.create(user_id="u1", name="t", max_retries=3) p.mark_processing() p.mark_failed("timeout") d = p.to_dict() assert d["status"] == "failed" assert d["error_message"] == "timeout" assert d["is_retryable"] is True def test_to_dict_datetime_isoformat(self): p = VoiceCloneProfile.create(user_id="u1", name="t") d = p.to_dict() parsed = datetime.fromisoformat(d["created_at"]) assert parsed.tzinfo is not None def test_to_dict_metadata(self): p = VoiceCloneProfile.create(user_id="u1", name="t", metadata={"key": "val", "num": 42}) d = p.to_dict() assert d["metadata"] == {"key": "val", "num": 42} def test_to_dict_all_fields_present(self): p = VoiceCloneProfile.create( user_id="u1", name="t", description="d", source_audio_url="https://ex.com/s.wav", voice_model="cosyvoice", language="zh-CN", gender="male", ) d = p.to_dict() expected_keys = { "id", "user_id", "name", "description", "status", "source_audio_url", "voice_id", "voice_model", "language", "gender", "error_message", "retry_count", "max_retries", "is_retryable", "is_ready", "metadata", "created_at", "updated_at", } assert set(d.keys()) == expected_keys