"""TTSJob 领域层单元测试 - tts_job.py""" import pytest from packages.domain.tts_job import ( TERMINAL_STATUSES, TTSJob, TTSJobStatus, ) class TestTTSJobStatus: """TTSJobStatus 枚举测试""" def test_all_statuses_have_values(self): for s in TTSJobStatus: assert isinstance(s.value, str) assert s.value def test_str_enum_behavior(self): assert TTSJobStatus.PENDING == "pending" assert isinstance(TTSJobStatus.PENDING, str) def test_terminal_statuses(self): assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES assert TTSJobStatus.FAILED in TERMINAL_STATUSES assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES def test_non_terminal_statuses(self): assert TTSJobStatus.PENDING not in TERMINAL_STATUSES assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES class TestTTSJobCreate: """TTSJob.create 工厂方法测试""" def test_create_basic(self): job = TTSJob.create(user_id="user-1", input_text="你好世界") assert job.id assert len(job.id) == 32 assert job.user_id == "user-1" assert job.input_text == "你好世界" assert job.status == TTSJobStatus.PENDING assert job.voice_id == "" assert job.voice_model == "" assert job.format == "mp3" assert job.sample_rate == 22050 assert job.retry_count == 0 assert job.max_retries == 3 assert job.metadata == {} def test_create_empty_user_id_raises(self): with pytest.raises(ValueError, match="user_id 不能为空"): TTSJob.create(user_id=" ", input_text="test") def test_create_empty_input_text_raises(self): with pytest.raises(ValueError, match="input_text 不能为空"): TTSJob.create(user_id="u1", input_text=" ") def test_create_text_too_long_raises(self): long_text = "a" * 10001 with pytest.raises(ValueError, match="input_text 长度不能超过 10000"): TTSJob.create(user_id="u1", input_text=long_text) def test_create_text_exactly_10000_ok(self): text = "a" * 10000 job = TTSJob.create(user_id="u1", input_text=text) assert len(job.input_text) == 10000 def test_create_invalid_format_raises(self): with pytest.raises(ValueError, match="不支持的输出格式"): TTSJob.create(user_id="u1", input_text="test", format="flac") def test_create_mp3_format(self): job = TTSJob.create(user_id="u1", input_text="test", format="mp3") assert job.format == "mp3" def test_create_wav_format(self): job = TTSJob.create(user_id="u1", input_text="test", format="wav") assert job.format == "wav" def test_create_pcm_format(self): job = TTSJob.create(user_id="u1", input_text="test", format="pcm") assert job.format == "pcm" def test_create_with_voice_id(self): job = TTSJob.create(user_id="u1", input_text="test", voice_id="voice-1") assert job.voice_id == "voice-1" def test_create_with_project_id(self): job = TTSJob.create(user_id="u1", input_text="test", project_id="proj-1") assert job.project_id == "proj-1" def test_create_with_clone_profile(self): job = TTSJob.create(user_id="u1", input_text="test", voice_clone_profile_id="vc-1") assert job.voice_clone_profile_id == "vc-1" def test_create_with_metadata(self): meta = {"source": "api", "priority": "high"} job = TTSJob.create(user_id="u1", input_text="test", metadata=meta) assert job.metadata == meta def test_create_none_metadata_defaults_empty(self): job = TTSJob.create(user_id="u1", input_text="test", metadata=None) assert job.metadata == {} def test_create_input_text_stripped(self): job = TTSJob.create(user_id=" u1 ", input_text=" hello ") assert job.user_id == "u1" assert job.input_text == "hello" def test_create_custom_max_retries(self): job = TTSJob.create(user_id="u1", input_text="test", max_retries=5) assert job.max_retries == 5 class TestTTSJobProperties: """属性测试""" def test_is_terminal_pending(self): job = TTSJob.create(user_id="u1", input_text="test") assert job.is_terminal is False def test_is_terminal_completed(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() job.mark_completed("http://audio.com/a.mp3") assert job.is_terminal is True def test_is_terminal_failed(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() job.mark_failed("error") assert job.is_terminal is True def test_is_retryable_failed_within_limit(self): job = TTSJob.create(user_id="u1", input_text="test", max_retries=3) job.mark_processing() job.mark_failed("error") assert job.is_retryable is True def test_is_retryable_failed_at_limit(self): job = TTSJob.create(user_id="u1", input_text="test", max_retries=1) job.mark_processing() job.mark_failed("error") job.retry_count = 1 assert job.is_retryable is False def test_is_completed_true(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() job.mark_completed("http://audio.com/a.mp3") assert job.is_completed is True def test_is_completed_no_url(self): """completed 状态但没有 output_audio_url 为空,is_completed 为 False""" job = TTSJob.create(user_id="u1", input_text="test") job.status = TTSJobStatus.COMPLETED job.output_audio_url = "" assert job.is_completed is False class TestTTSJobTransitions: """状态转换测试""" def test_pending_to_processing(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to(TTSJobStatus.PROCESSING) assert job.status == TTSJobStatus.PROCESSING def test_pending_to_failed(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to(TTSJobStatus.FAILED) assert job.status == TTSJobStatus.FAILED def test_pending_to_cancelled(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to(TTSJobStatus.CANCELLED) assert job.status == TTSJobStatus.CANCELLED def test_processing_to_completed(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to(TTSJobStatus.PROCESSING) job.transition_to(TTSJobStatus.COMPLETED) assert job.status == TTSJobStatus.COMPLETED def test_processing_to_failed(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to(TTSJobStatus.PROCESSING) job.transition_to(TTSJobStatus.FAILED) assert job.status == TTSJobStatus.FAILED def test_failed_to_pending_retry(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to(TTSJobStatus.PROCESSING) job.transition_to(TTSJobStatus.FAILED) job.transition_to(TTSJobStatus.PENDING) assert job.status == TTSJobStatus.PENDING def test_invalid_transition_raises(self): job = TTSJob.create(user_id="u1", input_text="test") with pytest.raises(ValueError, match="非法状态转换"): job.transition_to(TTSJobStatus.COMPLETED) # pending 不能直接到 completed def test_transition_with_string(self): job = TTSJob.create(user_id="u1", input_text="test") job.transition_to("processing") assert job.status == TTSJobStatus.PROCESSING def test_transition_invalid_string_raises(self): job = TTSJob.create(user_id="u1", input_text="test") with pytest.raises(ValueError, match="无效状态"): job.transition_to("invalid") def test_transition_updates_updated_at(self): job = TTSJob.create(user_id="u1", input_text="test") old = job.updated_at import time time.sleep(0.001) job.transition_to(TTSJobStatus.PROCESSING) assert job.updated_at >= old class TestTTSJobMarkMethods: """便捷标记方法测试""" def test_mark_processing(self): job = TTSJob.create(user_id="u1", input_text="test") job.error_message = "previous error" job.mark_processing() assert job.status == TTSJobStatus.PROCESSING assert job.started_at is not None assert job.error_message == "" def test_mark_completed(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() job.mark_completed( "http://example.com/audio.mp3", output_audio_key="audio/key.mp3", duration=5.5, file_size=102400, ) assert job.status == TTSJobStatus.COMPLETED assert job.output_audio_url == "http://example.com/audio.mp3" assert job.output_audio_key == "audio/key.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): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() with pytest.raises(ValueError, match="output_audio_url 不能为空"): job.mark_completed(" ") def test_mark_failed(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() job.mark_failed("API 调用超时") assert job.status == TTSJobStatus.FAILED assert job.error_message == "API 调用超时" def test_mark_cancelled(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_cancelled() assert job.status == TTSJobStatus.CANCELLED class TestTTSJobRetry: """重试逻辑测试""" def test_prepare_retry(self): job = TTSJob.create(user_id="u1", input_text="test", max_retries=3) 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_retryable_raises(self): job = TTSJob.create(user_id="u1", input_text="test", max_retries=0) job.mark_processing() job.mark_failed("error") with pytest.raises(ValueError, match="不可重试"): job.prepare_retry() def test_multiple_retries(self): job = TTSJob.create(user_id="u1", input_text="test", max_retries=3) for i in range(3): job.mark_processing() job.mark_failed(f"error-{i}") job.prepare_retry() assert job.retry_count == i + 1 # 第3次重试后 retry_count=3,等于 max_retries=3,不可再重试 assert job.is_retryable is False class TestTTSJobToDict: """to_dict 序列化测试""" def test_to_dict_contains_fields(self): job = TTSJob.create( user_id="u1", input_text="test", voice_id="voice-1", project_id="proj-1", ) d = job.to_dict() assert d["id"] == job.id assert d["user_id"] == "u1" assert d["input_text"] == "test" assert d["voice_id"] == "voice-1" assert d["status"] == "pending" assert d["is_retryable"] is False assert d["is_completed"] is False def test_to_dict_datetime_are_strings(self): job = TTSJob.create(user_id="u1", input_text="test") d = job.to_dict() assert isinstance(d["created_at"], str) assert isinstance(d["updated_at"], str) def test_to_dict_none_datetime(self): job = TTSJob.create(user_id="u1", input_text="test") d = job.to_dict() assert d["started_at"] is None assert d["completed_at"] is None def test_to_dict_after_completion(self): job = TTSJob.create(user_id="u1", input_text="test") job.mark_processing() job.mark_completed("http://test.mp3", duration=10.0) d = job.to_dict() assert d["status"] == "completed" assert d["duration"] == 10.0 assert d["is_completed"] is True assert d["started_at"] is not None assert d["completed_at"] is not None