"""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", 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" 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", 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" 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