Files
xiaoxia-saas/tests/unit/test_tts_job.py
T
xiaoxia a25e0b6220
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 8s
CI/CD Pipeline / Frontend Lint (push) Successful in 53s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix: CosyVoice 音色名 v3 后缀修复(418错误) (#213)
2026-07-11 01:28:32 +08:00

345 lines
13 KiB
Python

"""TTSJob 领域模型单元测试 — Phase 3 CosyVoice 集成."""
from __future__ import annotations
import pytest
from packages.domain.tts_job import TTSJob, TTSJobStatus
class TestTTSJobCreate:
"""测试 TTSJob.create() 工厂方法。"""
def test_create_success(self) -> None:
"""正常创建 TTS 任务。"""
job = TTSJob.create(
user_id="user_001",
input_text="这是一段测试文本",
voice_id="longxiaochun_v3",
voice_model="cosyvoice-v1",
project_id="project_001",
voice_clone_profile_id="profile_001",
sample_rate=22050,
format="mp3",
)
assert job.id
assert job.user_id == "user_001"
assert job.input_text == "这是一段测试文本"
assert job.voice_id == "longxiaochun_v3"
assert job.voice_model == "cosyvoice-v1"
assert job.project_id == "project_001"
assert job.voice_clone_profile_id == "profile_001"
assert job.status == TTSJobStatus.PENDING
assert job.sample_rate == 22050
assert job.format == "mp3"
assert job.retry_count == 0
assert job.max_retries == 3
def test_create_minimal(self) -> None:
"""使用最小参数创建。"""
job = TTSJob.create(user_id="user_001", input_text="测试文本")
assert job.user_id == "user_001"
assert job.input_text == "测试文本"
assert job.status == TTSJobStatus.PENDING
assert job.voice_id == ""
assert job.project_id == ""
assert job.voice_clone_profile_id == ""
assert job.format == "mp3"
def test_create_empty_user_id_raises(self) -> None:
"""空 user_id 应抛出 ValueError。"""
with pytest.raises(ValueError, match="user_id 不能为空"):
TTSJob.create(user_id="", input_text="测试")
def test_create_empty_input_text_raises(self) -> None:
"""空 input_text 应抛出 ValueError。"""
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="user_001", input_text="")
def test_create_whitespace_input_text_raises(self) -> None:
"""空白 input_text 应抛出 ValueError。"""
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="user_001", input_text=" ")
def test_create_input_text_too_long_raises(self) -> None:
"""input_text 超过 10000 字符应抛出 ValueError。"""
with pytest.raises(ValueError, match="input_text 长度不能超过 10000 字符"):
TTSJob.create(user_id="user_001", input_text="a" * 10001)
def test_create_invalid_format_raises(self) -> None:
"""不支持的输出格式应抛出 ValueError。"""
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="user_001", input_text="测试", format="aac")
def test_create_valid_formats(self) -> None:
"""所有支持的格式都应正常创建。"""
for fmt in ("mp3", "wav", "pcm"):
job = TTSJob.create(user_id="user_001", input_text="测试", format=fmt)
assert job.format == fmt
def test_create_strips_whitespace(self) -> None:
"""应去除首尾空白。"""
job = TTSJob.create(
user_id=" user_001 ",
input_text=" 测试文本 ",
voice_id=" voice_001 ",
)
assert job.user_id == "user_001"
assert job.input_text == "测试文本"
assert job.voice_id == "voice_001"
class TestTTSJobStatus:
"""测试状态相关属性和方法。"""
def test_initial_status_is_pending(self) -> None:
"""初始状态应为 PENDING。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
assert job.status == TTSJobStatus.PENDING
def test_is_terminal_pending(self) -> None:
"""PENDING 不是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
assert not job.is_terminal
def test_is_terminal_completed(self) -> None:
"""COMPLETED 是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.is_terminal
def test_is_terminal_failed(self) -> None:
"""FAILED 是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("合成失败")
assert job.is_terminal
def test_is_terminal_cancelled(self) -> None:
"""CANCELLED 是终态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_cancelled()
assert job.is_terminal
def test_is_retryable_not_failed(self) -> None:
"""非 FAILED 状态不可重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
assert not job.is_retryable
def test_is_retryable_failed_under_limit(self) -> None:
"""FAILED 且未超过重试上限时可重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("合成失败")
assert job.is_retryable
def test_is_retryable_failed_over_limit(self) -> None:
"""超过重试上限时不可重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试", max_retries=1)
job.mark_processing()
job.mark_failed("第一次失败")
job.prepare_retry()
job.mark_processing()
job.mark_failed("第二次失败")
assert not job.is_retryable
def test_is_completed_with_url(self) -> None:
"""COMPLETED 且有 output_audio_url 时应返回 True。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.is_completed
def test_is_completed_without_url(self) -> None:
"""COMPLETED 但无 output_audio_url 时应返回 False。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.status = TTSJobStatus.COMPLETED
job.output_audio_url = ""
assert not job.is_completed
class TestTTSJobTransitions:
"""测试状态转换。"""
def test_mark_processing(self) -> None:
"""PENDING → PROCESSING 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
assert job.status == TTSJobStatus.PROCESSING
assert job.error_message == ""
assert job.started_at is not None
def test_mark_completed(self) -> None:
"""PROCESSING → COMPLETED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(
output_audio_url="https://example.com/audio.mp3",
output_audio_key="tts/audio.mp3",
duration=5.5,
file_size=102400,
)
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "https://example.com/audio.mp3"
assert job.output_audio_key == "tts/audio.mp3"
assert job.duration == 5.5
assert job.file_size == 102400
assert job.completed_at is not None
assert job.error_message == ""
def test_mark_completed_empty_url_raises(self) -> None:
"""mark_completed 空 output_audio_url 应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
job.mark_completed(output_audio_url="")
def test_mark_completed_minimal(self) -> None:
"""mark_completed 最小参数。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
assert job.status == TTSJobStatus.COMPLETED
assert job.duration == 0.0
assert job.file_size == 0
def test_mark_failed(self) -> None:
"""PROCESSING → FAILED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("API 调用失败")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "API 调用失败"
def test_mark_cancelled_from_pending(self) -> None:
"""PENDING → CANCELLED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_mark_cancelled_from_processing(self) -> None:
"""PROCESSING → CANCELLED 转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
def test_invalid_transition_raises(self) -> None:
"""非法状态转换应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
with pytest.raises(ValueError, match="非法状态转换"):
job.mark_completed(output_audio_url="https://example.com/audio.mp3")
def test_invalid_status_string_raises(self) -> None:
"""无效状态字符串应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid_status")
def test_transition_to_with_string(self) -> None:
"""支持字符串形式的状态转换。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.transition_to("processing")
assert job.status == TTSJobStatus.PROCESSING
class TestTTSJobRetry:
"""测试重试逻辑。"""
def test_prepare_retry_success(self) -> None:
"""成功重试。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_failed("失败")
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_prepare_retry_not_failed_raises(self) -> None:
"""非 FAILED 状态重试应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_prepare_retry_over_limit_raises(self) -> None:
"""超过重试上限重试应抛出 ValueError。"""
job = TTSJob.create(user_id="user_001", input_text="测试", max_retries=1)
job.mark_processing()
job.mark_failed("第一次失败")
job.prepare_retry()
job.mark_processing()
job.mark_failed("第二次失败")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
class TestTTSJobToDict:
"""测试序列化。"""
def test_to_dict_contains_all_fields(self) -> None:
"""to_dict 应包含所有字段。"""
job = TTSJob.create(
user_id="user_001",
input_text="测试文本",
voice_id="longxiaochun_v3",
voice_model="cosyvoice-v1",
project_id="project_001",
voice_clone_profile_id="profile_001",
sample_rate=22050,
format="mp3",
max_retries=5,
metadata={"key": "value"},
)
result = job.to_dict()
assert result["id"] == job.id
assert result["user_id"] == "user_001"
assert result["input_text"] == "测试文本"
assert result["voice_id"] == "longxiaochun_v3"
assert result["voice_model"] == "cosyvoice-v1"
assert result["project_id"] == "project_001"
assert result["voice_clone_profile_id"] == "profile_001"
assert result["status"] == "pending"
assert result["sample_rate"] == 22050
assert result["format"] == "mp3"
assert result["retry_count"] == 0
assert result["max_retries"] == 5
assert result["is_retryable"] is False
assert result["is_completed"] is False
assert result["metadata"] == {"key": "value"}
assert result["started_at"] is None
assert result["completed_at"] is None
assert result["created_at"] is not None
assert result["updated_at"] is not None
def test_to_dict_after_completion(self) -> None:
"""任务完成后 to_dict 应反映最新状态。"""
job = TTSJob.create(user_id="user_001", input_text="测试")
job.mark_processing()
job.mark_completed(
output_audio_url="https://example.com/audio.mp3",
duration=10.5,
file_size=204800,
)
result = job.to_dict()
assert result["status"] == "completed"
assert result["output_audio_url"] == "https://example.com/audio.mp3"
assert result["duration"] == 10.5
assert result["file_size"] == 204800
assert result["is_completed"] is True
assert result["started_at"] is not None
assert result["completed_at"] is not None