"""TTSJob领域模型测试 — 状态机 + 状态转换 + 属性方法.""" from __future__ import annotations import pytest from packages.domain.tts_job import ( TERMINAL_STATUSES, TTSJob, TTSJobStatus, ) def _make_job( *, status: TTSJobStatus = TTSJobStatus.PENDING, retry_count: int = 0, max_retries: int = 3, output_audio_url: str = "", ) -> TTSJob: """快速创建测试用TTSJob.""" return TTSJob( id="job_1", user_id="user_1", input_text="测试文本", voice_id="female_warm", status=status, output_audio_url=output_audio_url, retry_count=retry_count, max_retries=max_retries, ) class TestTTSJobStatus: """TTSJobStatus 枚举测试.""" def test_all_statuses_exist(self): """所有5种状态都存在.""" assert TTSJobStatus.PENDING.value == "pending" assert TTSJobStatus.PROCESSING.value == "processing" assert TTSJobStatus.COMPLETED.value == "completed" assert TTSJobStatus.FAILED.value == "failed" assert TTSJobStatus.CANCELLED.value == "cancelled" def test_terminal_statuses(self): """终态集合包含COMPLETED/FAILED/CANCELLED.""" assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES assert TTSJobStatus.FAILED in TERMINAL_STATUSES assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES assert TTSJobStatus.PENDING not in TERMINAL_STATUSES assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES class TestTTSJobCreate: """TTSJob.create 创建测试.""" def test_create_pending_job(self): """创建成功,默认PENDING状态.""" job = TTSJob.create( user_id="u1", input_text="你好世界", voice_id="v1", ) assert job.id is not None assert len(job.id) == 32 assert job.user_id == "u1" assert job.input_text == "你好世界" assert job.voice_id == "v1" assert job.status == TTSJobStatus.PENDING assert job.retry_count == 0 assert job.max_retries == 3 assert job.sample_rate == 22050 def test_create_empty_user_id_raises(self): """空user_id抛异常.""" with pytest.raises(ValueError, match="user_id"): TTSJob.create(user_id=" ", input_text="hi", voice_id="v") def test_create_empty_text_raises(self): """空input_text抛异常.""" with pytest.raises(ValueError, match="input_text"): TTSJob.create(user_id="u1", input_text="", voice_id="v") def test_create_strips_whitespace(self): """首尾空白被去除.""" job = TTSJob.create( user_id=" u1 ", input_text=" 你好 ", voice_id=" v1 ", ) assert job.user_id == "u1" assert job.input_text == "你好" assert job.voice_id == "v1" class TestTerminalStatus: """is_terminal 终态判定测试.""" def test_pending_not_terminal(self): """PENDING不是终态.""" job = _make_job(status=TTSJobStatus.PENDING) assert job.is_terminal is False def test_processing_not_terminal(self): """PROCESSING不是终态.""" job = _make_job(status=TTSJobStatus.PROCESSING) assert job.is_terminal is False def test_completed_is_terminal(self): """COMPLETED是终态.""" job = _make_job(status=TTSJobStatus.COMPLETED) assert job.is_terminal is True def test_failed_is_terminal(self): """FAILED是终态.""" job = _make_job(status=TTSJobStatus.FAILED) assert job.is_terminal is True def test_cancelled_is_terminal(self): """CANCELLED是终态.""" job = _make_job(status=TTSJobStatus.CANCELLED) assert job.is_terminal is True class TestIsRetryable: """is_retryable 可重试判定测试.""" def test_failed_within_limit_is_retryable(self): """失败且未超上限→可重试.""" job = _make_job(status=TTSJobStatus.FAILED, retry_count=1, max_retries=3) assert job.is_retryable is True def test_failed_at_limit_not_retryable(self): """失败且已达上限→不可重试.""" job = _make_job(status=TTSJobStatus.FAILED, retry_count=3, max_retries=3) assert job.is_retryable is False def test_failed_over_limit_not_retryable(self): """失败且超上限→不可重试.""" job = _make_job(status=TTSJobStatus.FAILED, retry_count=5, max_retries=3) assert job.is_retryable is False def test_pending_not_retryable(self): """PENDING不可重试.""" job = _make_job(status=TTSJobStatus.PENDING) assert job.is_retryable is False def test_completed_not_retryable(self): """COMPLETED不可重试.""" job = _make_job(status=TTSJobStatus.COMPLETED) assert job.is_retryable is False def test_cancelled_not_retryable(self): """CANCELLED不可重试.""" job = _make_job(status=TTSJobStatus.CANCELLED) assert job.is_retryable is False class TestIsCompleted: """is_completed 完成判定测试.""" def test_completed_with_output_is_completed(self): """COMPLETED + 有输出URL→已完成.""" job = _make_job( status=TTSJobStatus.COMPLETED, output_audio_url="https://example.com/out.mp3", ) assert job.is_completed is True def test_completed_without_output_not_completed(self): """COMPLETED但无输出URL→不算完成.""" job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="") assert job.is_completed is False def test_pending_not_completed(self): """PENDING不是完成.""" job = _make_job(status=TTSJobStatus.PENDING) assert job.is_completed is False def test_failed_not_completed(self): """FAILED不是完成.""" job = _make_job(status=TTSJobStatus.FAILED) assert job.is_completed is False class TestStateTransitions: """状态机转换测试.""" def test_pending_to_processing(self): """PENDING → PROCESSING 合法.""" job = _make_job(status=TTSJobStatus.PENDING) job.transition_to(TTSJobStatus.PROCESSING) assert job.status == TTSJobStatus.PROCESSING def test_pending_to_failed(self): """PENDING → FAILED 合法.""" job = _make_job(status=TTSJobStatus.PENDING) job.transition_to(TTSJobStatus.FAILED) assert job.status == TTSJobStatus.FAILED def test_pending_to_cancelled(self): """PENDING → CANCELLED 合法.""" job = _make_job(status=TTSJobStatus.PENDING) job.transition_to(TTSJobStatus.CANCELLED) assert job.status == TTSJobStatus.CANCELLED def test_pending_to_completed_illegal(self): """PENDING → COMPLETED 非法.""" job = _make_job(status=TTSJobStatus.PENDING) with pytest.raises(ValueError, match="非法状态转换"): job.transition_to(TTSJobStatus.COMPLETED) def test_processing_to_completed(self): """PROCESSING → COMPLETED 合法.""" job = _make_job(status=TTSJobStatus.PROCESSING) job.transition_to(TTSJobStatus.COMPLETED) assert job.status == TTSJobStatus.COMPLETED def test_processing_to_failed(self): """PROCESSING → FAILED 合法.""" job = _make_job(status=TTSJobStatus.PROCESSING) job.transition_to(TTSJobStatus.FAILED) assert job.status == TTSJobStatus.FAILED def test_failed_to_pending_retry(self): """FAILED → PENDING 合法(重试).""" job = _make_job(status=TTSJobStatus.FAILED) job.transition_to(TTSJobStatus.PENDING) assert job.status == TTSJobStatus.PENDING def test_failed_to_completed_illegal(self): """FAILED → COMPLETED 非法.""" job = _make_job(status=TTSJobStatus.FAILED) with pytest.raises(ValueError): job.transition_to(TTSJobStatus.COMPLETED) def test_completed_to_anything_illegal(self): """COMPLETED → 任何状态都非法(终态不可转换).""" job = _make_job(status=TTSJobStatus.COMPLETED) with pytest.raises(ValueError): job.transition_to(TTSJobStatus.FAILED) with pytest.raises(ValueError): job.transition_to(TTSJobStatus.PENDING) def test_cancelled_to_anything_illegal(self): """CANCELLED → 任何状态都非法.""" job = _make_job(status=TTSJobStatus.CANCELLED) with pytest.raises(ValueError): job.transition_to(TTSJobStatus.PENDING) def test_transition_updates_updated_at(self): """状态转换更新updated_at.""" job = _make_job(status=TTSJobStatus.PENDING) old_updated = job.updated_at job.transition_to(TTSJobStatus.PROCESSING) assert job.updated_at >= old_updated def test_transition_with_string_status(self): """字符串状态也能转换.""" job = _make_job(status=TTSJobStatus.PENDING) job.transition_to("processing") assert job.status == TTSJobStatus.PROCESSING def test_transition_with_invalid_string_raises(self): """无效字符串状态抛异常.""" job = _make_job(status=TTSJobStatus.PENDING) with pytest.raises(ValueError, match="无效状态"): job.transition_to("invalid_status") class TestMarkProcessing: """mark_processing 标记处理中测试.""" def test_mark_processing_sets_status_and_time(self): """标记处理中更新状态+开始时间+清错误.""" job = _make_job(status=TTSJobStatus.PENDING) job.error_message = "旧错误" job.mark_processing() assert job.status == TTSJobStatus.PROCESSING assert job.started_at is not None assert job.error_message == "" class TestMarkCompleted: """mark_completed 标记完成测试.""" def test_mark_completed_success(self): """成功标记完成.""" job = _make_job(status=TTSJobStatus.PROCESSING) job.mark_completed( output_audio_url="https://example.com/out.mp3", output_audio_key="tts/jobs/job_1/out.mp3", duration=10.5, file_size=204800, ) assert job.status == TTSJobStatus.COMPLETED assert job.output_audio_url == "https://example.com/out.mp3" assert job.output_audio_key == "tts/jobs/job_1/out.mp3" assert job.duration == pytest.approx(10.5) assert job.file_size == 204800 assert job.completed_at is not None assert job.error_message == "" def test_mark_completed_empty_url_raises(self): """空URL抛异常.""" job = _make_job(status=TTSJobStatus.PROCESSING) with pytest.raises(ValueError, match="output_audio_url"): job.mark_completed(output_audio_url=" ") def test_mark_completed_strips_url(self): """URL首尾空白被去除.""" job = _make_job(status=TTSJobStatus.PROCESSING) job.mark_completed(output_audio_url=" https://example.com/out.mp3 ") assert job.output_audio_url == "https://example.com/out.mp3" def test_mark_completed_from_pending_illegal(self): """从PENDING直接标记完成非法(先processing).""" job = _make_job(status=TTSJobStatus.PENDING) with pytest.raises(ValueError, match="非法状态转换"): job.mark_completed(output_audio_url="https://x.com/out.mp3") class TestMarkFailed: """mark_failed 标记失败测试.""" def test_mark_failed_from_pending(self): """从PENDING标记失败.""" job = _make_job(status=TTSJobStatus.PENDING) job.mark_failed("网络超时") assert job.status == TTSJobStatus.FAILED assert job.error_message == "网络超时" def test_mark_failed_from_processing(self): """从PROCESSING标记失败.""" job = _make_job(status=TTSJobStatus.PROCESSING) job.mark_failed("合成失败") assert job.status == TTSJobStatus.FAILED assert job.error_message == "合成失败" def test_mark_failed_from_completed_illegal(self): """从COMPLETED标记失败非法.""" job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="https://x.com/out.mp3") with pytest.raises(ValueError): job.mark_failed("错误") class TestMarkCancelled: """mark_cancelled 标记取消测试.""" def test_cancel_from_pending(self): """从PENDING取消.""" job = _make_job(status=TTSJobStatus.PENDING) job.mark_cancelled() assert job.status == TTSJobStatus.CANCELLED def test_cancel_from_processing(self): """从PROCESSING取消.""" job = _make_job(status=TTSJobStatus.PROCESSING) job.mark_cancelled() assert job.status == TTSJobStatus.CANCELLED def test_cancel_from_completed_illegal(self): """从COMPLETED取消非法.""" job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="https://x.com/out.mp3") with pytest.raises(ValueError): job.mark_cancelled() class TestPrepareRetry: """prepare_retry 重试准备测试.""" def test_retry_resets_to_pending(self): """重试重置为PENDING,retry_count+1.""" job = _make_job(status=TTSJobStatus.FAILED, retry_count=0, max_retries=3) job.error_message = "失败了" 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_retry_at_max_raises(self): """已达重试上限时不能再重试.""" job = _make_job(status=TTSJobStatus.FAILED, retry_count=3, max_retries=3) with pytest.raises(ValueError, match="不可重试"): job.prepare_retry() def test_retry_from_pending_raises(self): """PENDING状态不能重试.""" job = _make_job(status=TTSJobStatus.PENDING, retry_count=0, max_retries=3) with pytest.raises(ValueError): job.prepare_retry() def test_multiple_retries_increment(self): """多次重试计数递增.""" job = _make_job(status=TTSJobStatus.FAILED, retry_count=0, max_retries=5) job.prepare_retry() assert job.retry_count == 1 # 模拟再次失败 job.mark_failed("又失败了") job.prepare_retry() assert job.retry_count == 2