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
345 lines
13 KiB
Python
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
|