"""VoiceCloneProfile 领域模型单元测试 — Phase 3 CosyVoice 集成.""" from __future__ import annotations import pytest from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus class TestVoiceCloneProfileCreate: """测试 VoiceCloneProfile.create() 工厂方法。""" def test_create_success(self) -> None: """正常创建音色克隆档案。""" profile = VoiceCloneProfile.create( user_id="user_001", name="我的音色", description="用于配音的自定义音色", source_audio_url="https://example.com/audio.wav", voice_model="cosyvoice-v1", language="zh-CN", gender="female", ) assert profile.id assert profile.user_id == "user_001" assert profile.name == "我的音色" assert profile.description == "用于配音的自定义音色" assert profile.status == VoiceCloneStatus.PENDING assert profile.source_audio_url == "https://example.com/audio.wav" assert profile.voice_model == "cosyvoice-v1" assert profile.language == "zh-CN" assert profile.gender == "female" assert profile.retry_count == 0 assert profile.max_retries == 3 assert profile.created_at assert profile.updated_at def test_create_minimal(self) -> None: """使用最小参数创建。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试音色") assert profile.user_id == "user_001" assert profile.name == "测试音色" assert profile.status == VoiceCloneStatus.PENDING assert profile.description == "" assert profile.source_audio_url == "" assert profile.language == "zh-CN" assert profile.gender == "unknown" def test_create_empty_user_id_raises(self) -> None: """空 user_id 应抛出 ValueError。""" with pytest.raises(ValueError, match="user_id 不能为空"): VoiceCloneProfile.create(user_id="", name="测试") def test_create_whitespace_user_id_raises(self) -> None: """空白 user_id 应抛出 ValueError。""" with pytest.raises(ValueError, match="user_id 不能为空"): VoiceCloneProfile.create(user_id=" ", name="测试") def test_create_empty_name_raises(self) -> None: """空 name 应抛出 ValueError。""" with pytest.raises(ValueError, match="name 不能为空"): VoiceCloneProfile.create(user_id="user_001", name="") def test_create_name_too_long_raises(self) -> None: """name 超过 100 字符应抛出 ValueError。""" with pytest.raises(ValueError, match="name 长度不能超过 100 字符"): VoiceCloneProfile.create(user_id="user_001", name="a" * 101) def test_create_strips_whitespace(self) -> None: """应去除首尾空白。""" profile = VoiceCloneProfile.create( user_id=" user_001 ", name=" 测试音色 ", description=" 描述 ", ) assert profile.user_id == "user_001" assert profile.name == "测试音色" assert profile.description == "描述" def test_create_gender_normalized(self) -> None: """gender 应转换为小写。""" profile = VoiceCloneProfile.create( user_id="user_001", name="测试", gender="FEMALE", ) assert profile.gender == "female" class TestVoiceCloneProfileStatus: """测试状态相关属性和方法。""" def test_initial_status_is_pending(self) -> None: """初始状态应为 PENDING。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") assert profile.status == VoiceCloneStatus.PENDING def test_is_terminal_pending(self) -> None: """PENDING 不是终态。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") assert not profile.is_terminal def test_is_terminal_ready(self) -> None: """READY 是终态。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_ready(voice_id="voice_001") assert profile.is_terminal def test_is_terminal_failed(self) -> None: """FAILED 是终态。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_failed("克隆失败") assert profile.is_terminal def test_is_retryable_not_failed(self) -> None: """非 FAILED 状态不可重试。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") assert not profile.is_retryable def test_is_retryable_failed_under_limit(self) -> None: """FAILED 且未超过重试上限时可重试。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_failed("克隆失败") assert profile.is_retryable def test_is_retryable_failed_over_limit(self) -> None: """超过重试上限时不可重试。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1) profile.mark_processing() profile.mark_failed("第一次失败") profile.prepare_retry() profile.mark_processing() profile.mark_failed("第二次失败") assert not profile.is_retryable def test_is_ready_with_voice_id(self) -> None: """READY 且有 voice_id 时应返回 True。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_ready(voice_id="voice_001") assert profile.is_ready def test_is_ready_without_voice_id(self) -> None: """READY 但无 voice_id 时应返回 False。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.status = VoiceCloneStatus.READY profile.voice_id = "" assert not profile.is_ready class TestVoiceCloneProfileTransitions: """测试状态转换。""" def test_mark_processing(self) -> None: """PENDING → PROCESSING 转换。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() assert profile.status == VoiceCloneStatus.PROCESSING assert profile.error_message == "" def test_mark_ready(self) -> None: """PROCESSING → READY 转换。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_ready(voice_id="voice_001") assert profile.status == VoiceCloneStatus.READY assert profile.voice_id == "voice_001" assert profile.error_message == "" def test_mark_ready_empty_voice_id_raises(self) -> None: """mark_ready 空 voice_id 应抛出 ValueError。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() with pytest.raises(ValueError, match="voice_id 不能为空"): profile.mark_ready(voice_id="") def test_mark_failed(self) -> None: """PROCESSING → FAILED 转换。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_failed("API 调用失败") assert profile.status == VoiceCloneStatus.FAILED assert profile.error_message == "API 调用失败" def test_mark_disabled_from_pending(self) -> None: """PENDING → DISABLED 转换。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_disabled() assert profile.status == VoiceCloneStatus.DISABLED def test_mark_disabled_from_ready(self) -> None: """READY → DISABLED 转换。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_ready(voice_id="voice_001") profile.mark_disabled() assert profile.status == VoiceCloneStatus.DISABLED def test_invalid_transition_raises(self) -> None: """非法状态转换应抛出 ValueError。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") with pytest.raises(ValueError, match="非法状态转换"): profile.mark_ready(voice_id="voice_001") # PENDING → READY 非法 def test_invalid_status_string_raises(self) -> None: """无效状态字符串应抛出 ValueError。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") with pytest.raises(ValueError, match="无效状态"): profile.transition_to("invalid_status") def test_transition_to_with_string(self) -> None: """支持字符串形式的状态转换。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.transition_to("processing") assert profile.status == VoiceCloneStatus.PROCESSING class TestVoiceCloneProfileRetry: """测试重试逻辑。""" def test_prepare_retry_success(self) -> None: """成功重试。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_failed("失败") profile.prepare_retry() assert profile.status == VoiceCloneStatus.PENDING assert profile.retry_count == 1 assert profile.error_message == "" assert profile.voice_id == "" def test_prepare_retry_not_failed_raises(self) -> None: """非 FAILED 状态重试应抛出 ValueError。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") with pytest.raises(ValueError, match="不可重试"): profile.prepare_retry() def test_prepare_retry_over_limit_raises(self) -> None: """超过重试上限重试应抛出 ValueError。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1) profile.mark_processing() profile.mark_failed("第一次失败") profile.prepare_retry() profile.mark_processing() profile.mark_failed("第二次失败") with pytest.raises(ValueError, match="不可重试"): profile.prepare_retry() class TestVoiceCloneProfileToDict: """测试序列化。""" def test_to_dict_contains_all_fields(self) -> None: """to_dict 应包含所有字段。""" profile = VoiceCloneProfile.create( user_id="user_001", name="测试音色", description="描述", source_audio_url="https://example.com/audio.wav", voice_model="cosyvoice-v1", language="zh-CN", gender="female", max_retries=5, metadata={"key": "value"}, ) result = profile.to_dict() assert result["id"] == profile.id assert result["user_id"] == "user_001" assert result["name"] == "测试音色" assert result["description"] == "描述" assert result["status"] == "pending" assert result["source_audio_url"] == "https://example.com/audio.wav" assert result["voice_model"] == "cosyvoice-v1" assert result["language"] == "zh-CN" assert result["gender"] == "female" assert result["retry_count"] == 0 assert result["max_retries"] == 5 assert result["is_retryable"] is False assert result["is_ready"] is False assert result["metadata"] == {"key": "value"} assert result["created_at"] is not None assert result["updated_at"] is not None def test_to_dict_after_state_change(self) -> None: """状态变更后 to_dict 应反映最新状态。""" profile = VoiceCloneProfile.create(user_id="user_001", name="测试") profile.mark_processing() profile.mark_ready(voice_id="voice_001") result = profile.to_dict() assert result["status"] == "ready" assert result["voice_id"] == "voice_001" assert result["is_ready"] is True