"""VoiceCloneProfile 领域模型单元测试.""" from __future__ import annotations from datetime import datetime, timezone from time import sleep import pytest from packages.domain.voice_clone_profile import ( TERMINAL_STATUSES, VoiceCloneProfile, VoiceCloneStatus, ) # ── 枚举测试 ────────────────────────────────────────────────────────────────── class TestVoiceCloneStatus: """VoiceCloneStatus 枚举测试.""" 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_count(self): """共5种状态.""" assert len(VoiceCloneStatus) == 5 def test_is_str_enum(self): """是StrEnum,可与字符串直接比较.""" assert VoiceCloneStatus.PENDING == "pending" assert VoiceCloneStatus.READY + "" == "ready" def test_from_string(self): """从字符串构建枚举.""" assert VoiceCloneStatus("pending") == VoiceCloneStatus.PENDING assert VoiceCloneStatus("ready") == VoiceCloneStatus.READY def test_from_string_invalid(self): """无效字符串抛出ValueError.""" with pytest.raises(ValueError): VoiceCloneStatus("invalid_status") # ── 终态集合测试 ────────────────────────────────────────────────────────────── class TestTerminalStatuses: """TERMINAL_STATUSES 终态集合测试.""" def test_ready_is_terminal(self): """ready是终态.""" assert VoiceCloneStatus.READY in TERMINAL_STATUSES def test_failed_is_terminal(self): """failed是终态.""" assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES def test_disabled_is_terminal(self): """disabled是终态.""" assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES def test_pending_not_terminal(self): """pending不是终态.""" assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES def test_processing_not_terminal(self): """processing不是终态.""" assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES def test_terminal_count(self): """共3个终态.""" assert len(TERMINAL_STATUSES) == 3 # ── 工厂方法测试 ────────────────────────────────────────────────────────────── class TestVoiceCloneProfileCreate: """VoiceCloneProfile.create 工厂方法测试.""" def test_create_basic(self): """基础创建.""" p = VoiceCloneProfile.create(user_id="user123", name="我的音色") assert p.id # 自动生成 assert p.user_id == "user123" assert p.name == "我的音色" assert p.status == VoiceCloneStatus.PENDING assert p.retry_count == 0 assert p.max_retries == 3 def test_create_with_description(self): """带描述创建.""" p = VoiceCloneProfile.create(user_id="u1", name="T", description=" 测试描述 ") assert p.description == "测试描述" # strip了 def test_create_with_source_audio(self): """带源音频URL创建.""" url = "https://example.com/audio.wav" p = VoiceCloneProfile.create(user_id="u1", name="T", source_audio_url=url) assert p.source_audio_url == url def test_create_with_language(self): """指定语言.""" p = VoiceCloneProfile.create(user_id="u1", name="T", language="en-US") assert p.language == "en-US" def test_create_gender_normalized(self): """性别自动转小写.""" p = VoiceCloneProfile.create(user_id="u1", name="T", gender="Male") assert p.gender == "male" def test_create_custom_max_retries(self): """自定义最大重试次数.""" p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=5) assert p.max_retries == 5 def test_create_metadata(self): """元数据.""" meta = {"age": 30, "accent": "北方"} p = VoiceCloneProfile.create(user_id="u1", name="T", metadata=meta) assert p.metadata == meta # 不是同一个对象引用(深拷贝?) assert p.metadata is not meta or p.metadata == meta def test_create_metadata_none(self): """metadata为None时默认为空dict.""" p = VoiceCloneProfile.create(user_id="u1", name="T", metadata=None) assert p.metadata == {} def test_create_user_id_stripped(self): """user_id去除空白.""" p = VoiceCloneProfile.create(user_id=" user123 ", name="T") assert p.user_id == "user123" def test_create_name_stripped(self): """name去除空白.""" p = VoiceCloneProfile.create(user_id="u1", name=" 我的音色 ") assert p.name == "我的音色" def test_create_empty_user_id(self): """空user_id抛错.""" with pytest.raises(ValueError, match="user_id"): VoiceCloneProfile.create(user_id="", name="T") def test_create_whitespace_user_id(self): """纯空白user_id抛错.""" with pytest.raises(ValueError, match="user_id"): VoiceCloneProfile.create(user_id=" ", name="T") def test_create_empty_name(self): """空name抛错.""" with pytest.raises(ValueError, match="name"): VoiceCloneProfile.create(user_id="u1", name="") def test_create_whitespace_name(self): """纯空白name抛错.""" with pytest.raises(ValueError, match="name"): VoiceCloneProfile.create(user_id="u1", name=" ") def test_create_name_too_long(self): """name超过100字符抛错.""" long_name = "a" * 101 with pytest.raises(ValueError, match="100"): VoiceCloneProfile.create(user_id="u1", name=long_name) def test_create_name_exactly_100(self): """name恰好100字符正常.""" name = "a" * 100 p = VoiceCloneProfile.create(user_id="u1", name=name) assert p.name == name def test_create_has_created_at(self): """创建后有created_at时间戳.""" p = VoiceCloneProfile.create(user_id="u1", name="T") assert isinstance(p.created_at, datetime) assert p.created_at.tzinfo is not None # 有时区 def test_create_has_updated_at(self): """创建后有updated_at时间戳.""" p = VoiceCloneProfile.create(user_id="u1", name="T") assert isinstance(p.updated_at, datetime) def test_create_id_is_hex(self): """id是32位hex字符串(uuid4 hex).""" p = VoiceCloneProfile.create(user_id="u1", name="T") assert len(p.id) == 32 # 全部是hex字符 int(p.id, 16) # 不抛错就是hex # ── 属性测试 ────────────────────────────────────────────────────────────────── class TestVoiceCloneProfileProperties: """VoiceCloneProfile 属性测试.""" def test_is_terminal_pending(self): """pending不是终态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") assert p.is_terminal is False def test_is_terminal_processing(self): """processing不是终态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() assert p.is_terminal is False def test_is_terminal_ready(self): """ready是终态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("voice_001") assert p.is_terminal is True def test_is_terminal_failed(self): """failed是终态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("超时") assert p.is_terminal is True def test_is_terminal_disabled(self): """disabled是终态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_disabled() assert p.is_terminal is True def test_is_retryable_failed_within_limit(self): """失败且未超过重试次数,可重试.""" p = VoiceCloneProfile.create(user_id="u1", name="T", 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="T", max_retries=3) p.mark_processing() p.mark_failed("e1") p.prepare_retry() # retry_count=1 p.mark_processing() p.mark_failed("e2") p.prepare_retry() # retry_count=2 p.mark_processing() p.mark_failed("e3") p.prepare_retry() # retry_count=3 p.mark_processing() p.mark_failed("e4") # retry_count=3, max=3 assert p.is_retryable is False def test_is_retryable_pending(self): """pending状态不可重试.""" p = VoiceCloneProfile.create(user_id="u1", name="T") assert p.is_retryable is False def test_is_retryable_ready(self): """ready状态不可重试.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("v1") assert p.is_retryable is False def test_is_retryable_processing(self): """processing状态不可重试.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() assert p.is_retryable is False def test_is_ready_with_voice_id(self): """ready状态且有voice_id,is_ready为True.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("voice_001") 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="T") p.status = VoiceCloneStatus.READY # 手动设为ready但无voice_id p.voice_id = "" assert p.is_ready is False def test_is_ready_pending(self): """pending状态is_ready为False.""" p = VoiceCloneProfile.create(user_id="u1", name="T") assert p.is_ready is False def test_is_ready_failed(self): """failed状态is_ready为False.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("err") assert p.is_ready is False # ── 状态转换测试 ────────────────────────────────────────────────────────────── class TestTransitionTo: """transition_to 状态转换测试.""" def test_pending_to_processing(self): """pending → processing 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.transition_to(VoiceCloneStatus.PROCESSING) assert p.status == VoiceCloneStatus.PROCESSING def test_pending_to_failed(self): """pending → failed 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.transition_to(VoiceCloneStatus.FAILED) assert p.status == VoiceCloneStatus.FAILED def test_pending_to_disabled(self): """pending → disabled 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_pending_to_ready_invalid(self): """pending → ready 非法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") with pytest.raises(ValueError, match="非法状态转换"): p.transition_to(VoiceCloneStatus.READY) def test_processing_to_ready(self): """processing → ready 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.transition_to(VoiceCloneStatus.READY) assert p.status == VoiceCloneStatus.READY def test_processing_to_failed(self): """processing → failed 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.transition_to(VoiceCloneStatus.FAILED) assert p.status == VoiceCloneStatus.FAILED def test_processing_to_disabled(self): """processing → disabled 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_failed_to_pending(self): """failed → pending 合法(重试).""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("err") p.transition_to(VoiceCloneStatus.PENDING) assert p.status == VoiceCloneStatus.PENDING def test_failed_to_ready_invalid(self): """failed → ready 非法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("err") with pytest.raises(ValueError): p.transition_to(VoiceCloneStatus.READY) def test_ready_to_disabled(self): """ready → disabled 合法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("v1") p.transition_to(VoiceCloneStatus.DISABLED) assert p.status == VoiceCloneStatus.DISABLED def test_disabled_to_pending_invalid(self): """disabled → pending 非法.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_disabled() with pytest.raises(ValueError): p.transition_to(VoiceCloneStatus.PENDING) def test_transition_with_string(self): """字符串输入的状态转换.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.transition_to("processing") assert p.status == VoiceCloneStatus.PROCESSING def test_transition_with_invalid_string(self): """无效字符串状态抛错.""" p = VoiceCloneProfile.create(user_id="u1", name="T") with pytest.raises(ValueError, match="无效状态"): p.transition_to("invalid") def test_transition_updates_updated_at(self): """状态转换更新updated_at.""" p = VoiceCloneProfile.create(user_id="u1", name="T") old_updated = p.updated_at sleep(0.01) p.transition_to(VoiceCloneStatus.PROCESSING) assert p.updated_at > old_updated def test_transition_error_message_contains_statuses(self): """错误信息包含源状态和目标状态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") with pytest.raises(ValueError) as exc_info: p.transition_to(VoiceCloneStatus.READY) msg = str(exc_info.value) assert "pending" in msg assert "ready" in msg # ── 操作方法测试 ────────────────────────────────────────────────────────────── class TestMarkMethods: """mark_* 系列方法测试.""" def test_mark_processing_clears_error(self): """mark_processing 清除错误信息.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.error_message = "previous error" p.mark_processing() assert p.error_message == "" def test_mark_processing_from_pending(self): """从pending标记为processing.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() assert p.status == VoiceCloneStatus.PROCESSING def test_mark_ready_with_voice_id(self): """mark_ready 正常标记.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("voice_001") assert p.status == VoiceCloneStatus.READY assert p.voice_id == "voice_001" def test_mark_ready_clears_error(self): """mark_ready 清除错误信息.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.error_message = "some error" p.mark_ready("v1") assert p.error_message == "" def test_mark_ready_empty_voice_id(self): """空voice_id抛错.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() with pytest.raises(ValueError, match="voice_id"): p.mark_ready("") def test_mark_ready_whitespace_voice_id(self): """纯空白voice_id抛错.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() with pytest.raises(ValueError): p.mark_ready(" ") def test_mark_ready_strips_voice_id(self): """voice_id去除空白.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready(" voice_001 ") assert p.voice_id == "voice_001" def test_mark_failed_sets_error(self): """mark_failed 设置错误信息.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("连接超时") assert p.error_message == "连接超时" def test_mark_failed_from_pending(self): """从pending直接失败.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_failed("验证失败") assert p.status == VoiceCloneStatus.FAILED assert p.error_message == "验证失败" def test_mark_disabled_from_pending(self): """从pending禁用.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_disabled() assert p.status == VoiceCloneStatus.DISABLED def test_mark_disabled_from_ready(self): """从ready禁用.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("v1") p.mark_disabled() assert p.status == VoiceCloneStatus.DISABLED # ── 重试逻辑测试 ────────────────────────────────────────────────────────────── class TestPrepareRetry: """prepare_retry 重试逻辑测试.""" def test_prepare_retry_basic(self): """基础重试成功.""" p = VoiceCloneProfile.create(user_id="u1", 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 def test_prepare_retry_clears_error(self): """重试清除错误信息.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("big error") p.prepare_retry() assert p.error_message == "" def test_prepare_retry_clears_voice_id(self): """重试清除voice_id.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.voice_id = "old_voice" p.mark_processing() p.mark_failed("err") p.prepare_retry() assert p.voice_id == "" def test_prepare_retry_not_failed(self): """非failed状态不可重试.""" p = VoiceCloneProfile.create(user_id="u1", name="T") with pytest.raises(ValueError, match="不可重试"): p.prepare_retry() def test_prepare_retry_exceeds_max(self): """超过最大重试次数不可重试.""" p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=1) p.mark_processing() p.mark_failed("e1") p.prepare_retry() # retry_count=1 p.mark_processing() p.mark_failed("e2") with pytest.raises(ValueError, match="不可重试"): p.prepare_retry() def test_prepare_retry_error_has_details(self): """错误信息包含详细状态.""" p = VoiceCloneProfile.create(user_id="u1", name="T") with pytest.raises(ValueError) as exc_info: p.prepare_retry() msg = str(exc_info.value) assert "pending" in msg assert "retry_count" in msg assert "max_retries" in msg # ── 序列化测试 ──────────────────────────────────────────────────────────────── class TestToDict: """to_dict 序列化测试.""" def test_to_dict_keys(self): """序列化字典包含所有预期字段.""" p = VoiceCloneProfile.create(user_id="u1", name="测试音色") 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 def test_to_dict_values(self): """序列化值正确.""" p = VoiceCloneProfile.create( user_id="user123", name="我的音色", description="测试用", language="zh-CN", gender="female", max_retries=5, metadata={"source": "upload"}, ) d = p.to_dict() assert d["user_id"] == "user123" assert d["name"] == "我的音色" assert d["description"] == "测试用" assert d["status"] == "pending" assert d["language"] == "zh-CN" assert d["gender"] == "female" assert d["retry_count"] == 0 assert d["max_retries"] == 5 assert d["is_retryable"] is False assert d["is_ready"] is False assert d["metadata"] == {"source": "upload"} def test_to_dict_ready_status(self): """ready状态下序列化正确.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_ready("voice_001") d = p.to_dict() assert d["status"] == "ready" assert d["voice_id"] == "voice_001" assert d["is_ready"] is True assert d["is_retryable"] is False def test_to_dict_failed_status(self): """failed状态下序列化正确.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() p.mark_failed("超时错误") d = p.to_dict() assert d["status"] == "failed" assert d["error_message"] == "超时错误" assert d["is_retryable"] is True def test_to_dict_datetime_format(self): """时间字段是ISO格式字符串.""" p = VoiceCloneProfile.create(user_id="u1", name="T") d = p.to_dict() # ISO格式可以被datetime解析 datetime.fromisoformat(d["created_at"]) datetime.fromisoformat(d["updated_at"]) def test_to_dict_with_updated_at_after_transition(self): """状态转换后updated_at被序列化.""" p = VoiceCloneProfile.create(user_id="u1", name="T") p.mark_processing() d = p.to_dict() assert d["updated_at"] is not None assert isinstance(d["updated_at"], str)