From 404f9f9435da9350a7e0043d17790607a99f202c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 16:24:04 +0800 Subject: [PATCH] =?UTF-8?q?test(wave177):=20voice=5Fclone=5Fprofile?= =?UTF-8?q?=E9=9F=B3=E8=89=B2=E5=85=8B=E9=9A=86=E6=A1=A3=E6=A1=88=20+58?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/domain/test_voice_clone_profile.py | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100755 tests/unit/domain/test_voice_clone_profile.py diff --git a/tests/unit/domain/test_voice_clone_profile.py b/tests/unit/domain/test_voice_clone_profile.py new file mode 100755 index 000000000..fc305f5d6 --- /dev/null +++ b/tests/unit/domain/test_voice_clone_profile.py @@ -0,0 +1,501 @@ +"""VoiceCloneProfile 音色克隆档案单测. + +覆盖:状态枚举、create创建校验、状态机转换、标记方法、 +重试逻辑、属性判断、to_dict序列化。 +""" + +from __future__ import annotations + +from datetime import 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(timezone.utc) + profile = VoiceCloneProfile.create(user_id="u1", name="t") + after = datetime.now(timezone.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 -- 2.54.0