test(wave177): voice_clone_profile 音色克隆配置 +58测 #1131
+501
@@ -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
|
||||
Reference in New Issue
Block a user