"""TTSJob 领域模型单元测试.""" from __future__ import annotations from datetime import UTC, datetime, timezone from time import sleep import pytest from packages.domain.tts_job import ( TERMINAL_STATUSES, TTSJob, TTSJobStatus, ) # ── 枚举测试 ────────────────────────────────────────────────────────────────── class TestTTSJobStatus: """TTSJobStatus 枚举测试.""" def test_status_values(self): """状态值正确.""" 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_status_count(self): """共5种状态.""" assert len(TTSJobStatus) == 5 def test_is_str_enum(self): """是StrEnum,可与字符串直接比较.""" assert TTSJobStatus.PENDING == "pending" assert TTSJobStatus.COMPLETED == "completed" def test_from_string(self): """从字符串构建枚举.""" assert TTSJobStatus("pending") == TTSJobStatus.PENDING assert TTSJobStatus("completed") == TTSJobStatus.COMPLETED def test_from_string_invalid(self): """无效字符串抛出ValueError.""" with pytest.raises(ValueError): TTSJobStatus("invalid_status") # ── 终态集合测试 ────────────────────────────────────────────────────────────── class TestTerminalStatuses: """TERMINAL_STATUSES 终态集合测试.""" def test_completed_is_terminal(self): """completed是终态.""" assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES def test_failed_is_terminal(self): """failed是终态.""" assert TTSJobStatus.FAILED in TERMINAL_STATUSES def test_cancelled_is_terminal(self): """cancelled是终态.""" assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES def test_pending_not_terminal(self): """pending不是终态.""" assert TTSJobStatus.PENDING not in TERMINAL_STATUSES def test_processing_not_terminal(self): """processing不是终态.""" assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES def test_terminal_count(self): """共3个终态.""" assert len(TERMINAL_STATUSES) == 3 # ── 工厂方法测试 ────────────────────────────────────────────────────────────── class TestTTSJobCreate: """TTSJob.create 工厂方法测试.""" def test_create_basic(self): """基础创建.""" job = TTSJob.create(user_id="user123", input_text="你好世界") assert job.id assert job.user_id == "user123" assert job.input_text == "你好世界" assert job.status == TTSJobStatus.PENDING assert job.retry_count == 0 assert job.max_retries == 3 def test_create_with_voice_id(self): """带音色ID创建.""" job = TTSJob.create(user_id="u1", input_text="hi", voice_id="voice_001") assert job.voice_id == "voice_001" def test_create_with_project_id(self): """带项目ID创建.""" job = TTSJob.create(user_id="u1", input_text="hi", project_id="proj_001") assert job.project_id == "proj_001" def test_create_with_voice_clone_profile(self): """带音色克隆档案ID创建.""" job = TTSJob.create(user_id="u1", input_text="hi", voice_clone_profile_id="vcp_001") assert job.voice_clone_profile_id == "vcp_001" def test_create_custom_sample_rate(self): """自定义采样率.""" job = TTSJob.create(user_id="u1", input_text="hi", sample_rate=44100) assert job.sample_rate == 44100 def test_create_default_sample_rate(self): """默认采样率22050.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.sample_rate == 22050 @pytest.mark.parametrize("fmt", ["mp3", "wav", "pcm"]) def test_create_valid_formats(self, fmt: str): """支持的输出格式.""" job = TTSJob.create(user_id="u1", input_text="hi", format=fmt) assert job.format == fmt def test_create_default_format(self): """默认格式mp3.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.format == "mp3" def test_create_invalid_format(self): """不支持的格式抛错.""" with pytest.raises(ValueError, match="不支持的输出格式"): TTSJob.create(user_id="u1", input_text="hi", format="aac") def test_create_custom_max_retries(self): """自定义最大重试次数.""" job = TTSJob.create(user_id="u1", input_text="hi", max_retries=5) assert job.max_retries == 5 def test_create_metadata(self): """元数据.""" meta = {"priority": "high", "source": "api"} job = TTSJob.create(user_id="u1", input_text="hi", metadata=meta) assert job.metadata == meta def test_create_metadata_none(self): """metadata为None时默认为空dict.""" job = TTSJob.create(user_id="u1", input_text="hi", metadata=None) assert job.metadata == {} def test_create_strips_text(self): """input_text去除首尾空白.""" job = TTSJob.create(user_id="u1", input_text=" 你好世界 ") assert job.input_text == "你好世界" def test_create_strips_user_id(self): """user_id去除空白.""" job = TTSJob.create(user_id=" user123 ", input_text="hi") assert job.user_id == "user123" def test_create_empty_user_id(self): """空user_id抛错.""" with pytest.raises(ValueError, match="user_id"): TTSJob.create(user_id="", input_text="hi") def test_create_whitespace_user_id(self): """纯空白user_id抛错.""" with pytest.raises(ValueError, match="user_id"): TTSJob.create(user_id=" ", input_text="hi") def test_create_empty_input_text(self): """空input_text抛错.""" with pytest.raises(ValueError, match="input_text"): TTSJob.create(user_id="u1", input_text="") def test_create_whitespace_input_text(self): """纯空白input_text抛错.""" with pytest.raises(ValueError, match="input_text"): TTSJob.create(user_id="u1", input_text=" \n ") def test_create_input_text_too_long(self): """input_text超过10000字符抛错.""" long_text = "a" * 10001 with pytest.raises(ValueError, match="10000"): TTSJob.create(user_id="u1", input_text=long_text) def test_create_input_text_exactly_10000(self): """input_text恰好10000字符正常.""" text = "a" * 10000 job = TTSJob.create(user_id="u1", input_text=text) assert job.input_text == text def test_create_has_timestamps(self): """创建后有时间戳.""" job = TTSJob.create(user_id="u1", input_text="hi") assert isinstance(job.created_at, datetime) assert isinstance(job.updated_at, datetime) assert job.created_at.tzinfo is not None assert job.started_at is None assert job.completed_at is None def test_create_default_values(self): """默认值正确.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.voice_id == "" assert job.voice_model == "" assert job.project_id == "" assert job.voice_clone_profile_id == "" assert job.output_audio_url == "" assert job.output_audio_key == "" assert job.duration == 0.0 assert job.file_size == 0 def test_create_id_is_hex(self): """id是32位hex字符串.""" job = TTSJob.create(user_id="u1", input_text="hi") assert len(job.id) == 32 int(job.id, 16) # ── 属性测试 ────────────────────────────────────────────────────────────────── class TestTTSJobProperties: """TTSJob 属性测试.""" def test_is_terminal_pending(self): """pending不是终态.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.is_terminal is False def test_is_terminal_processing(self): """processing不是终态.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() assert job.is_terminal is False def test_is_terminal_completed(self): """completed是终态.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed("https://example.com/audio.mp3") assert job.is_terminal is True def test_is_terminal_failed(self): """failed是终态.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("超时") assert job.is_terminal is True def test_is_terminal_cancelled(self): """cancelled是终态.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_cancelled() assert job.is_terminal is True def test_is_retryable_failed_within_limit(self): """失败且未超过重试次数,可重试.""" job = TTSJob.create(user_id="u1", input_text="hi", 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="hi", max_retries=1) job.mark_processing() job.mark_failed("e1") job.prepare_retry() # retry_count=1 job.mark_processing() job.mark_failed("e2") assert job.is_retryable is False def test_is_retryable_pending(self): """pending状态不可重试.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.is_retryable is False def test_is_retryable_completed(self): """completed状态不可重试.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed("https://a.mp3") assert job.is_retryable is False def test_is_retryable_cancelled(self): """cancelled状态不可重试.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_cancelled() assert job.is_retryable is False def test_is_completed_with_output(self): """completed状态且有输出URL,is_completed为True.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed("https://example.com/audio.mp3") assert job.is_completed is True def test_is_completed_no_output(self): """completed状态但无输出URL,is_completed为False.""" job = TTSJob.create(user_id="u1", input_text="hi") job.status = TTSJobStatus.COMPLETED job.output_audio_url = "" assert job.is_completed is False def test_is_completed_pending(self): """pending状态is_completed为False.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.is_completed is False def test_is_completed_failed(self): """failed状态is_completed为False.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("err") assert job.is_completed is False # ── 状态转换测试 ────────────────────────────────────────────────────────────── class TestTransitionTo: """transition_to 状态转换测试.""" def test_pending_to_processing(self): """pending → processing 合法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.transition_to(TTSJobStatus.PROCESSING) assert job.status == TTSJobStatus.PROCESSING def test_pending_to_failed(self): """pending → failed 合法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.transition_to(TTSJobStatus.FAILED) assert job.status == TTSJobStatus.FAILED def test_pending_to_cancelled(self): """pending → cancelled 合法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.transition_to(TTSJobStatus.CANCELLED) assert job.status == TTSJobStatus.CANCELLED def test_pending_to_completed_invalid(self): """pending → completed 非法.""" job = TTSJob.create(user_id="u1", input_text="hi") with pytest.raises(ValueError, match="非法状态转换"): job.transition_to(TTSJobStatus.COMPLETED) def test_processing_to_completed(self): """processing → completed 合法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.transition_to(TTSJobStatus.COMPLETED) assert job.status == TTSJobStatus.COMPLETED def test_processing_to_failed(self): """processing → failed 合法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.transition_to(TTSJobStatus.FAILED) assert job.status == TTSJobStatus.FAILED def test_processing_to_cancelled(self): """processing → cancelled 合法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.transition_to(TTSJobStatus.CANCELLED) assert job.status == TTSJobStatus.CANCELLED def test_failed_to_pending(self): """failed → pending 合法(重试).""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("err") job.transition_to(TTSJobStatus.PENDING) assert job.status == TTSJobStatus.PENDING def test_failed_to_completed_invalid(self): """failed → completed 非法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("err") with pytest.raises(ValueError): job.transition_to(TTSJobStatus.COMPLETED) def test_completed_to_pending_invalid(self): """completed → pending 非法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed("https://a.mp3") with pytest.raises(ValueError): job.transition_to(TTSJobStatus.PENDING) def test_cancelled_to_pending_invalid(self): """cancelled → pending 非法.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_cancelled() with pytest.raises(ValueError): job.transition_to(TTSJobStatus.PENDING) def test_transition_with_string(self): """字符串输入的状态转换.""" job = TTSJob.create(user_id="u1", input_text="hi") job.transition_to("processing") assert job.status == TTSJobStatus.PROCESSING def test_transition_with_invalid_string(self): """无效字符串状态抛错.""" job = TTSJob.create(user_id="u1", input_text="hi") with pytest.raises(ValueError, match="无效状态"): job.transition_to("invalid") def test_transition_updates_updated_at(self): """状态转换更新updated_at.""" job = TTSJob.create(user_id="u1", input_text="hi") old_updated = job.updated_at sleep(0.01) job.transition_to(TTSJobStatus.PROCESSING) assert job.updated_at > old_updated def test_transition_error_message_contains_statuses(self): """错误信息包含源状态和目标状态.""" job = TTSJob.create(user_id="u1", input_text="hi") with pytest.raises(ValueError) as exc_info: job.transition_to(TTSJobStatus.COMPLETED) msg = str(exc_info.value) assert "pending" in msg assert "completed" in msg # ── 操作方法测试 ────────────────────────────────────────────────────────────── class TestMarkMethods: """mark_* 系列方法测试.""" def test_mark_processing_sets_started_at(self): """mark_processing 设置started_at.""" job = TTSJob.create(user_id="u1", input_text="hi") assert job.started_at is None job.mark_processing() assert job.started_at is not None assert isinstance(job.started_at, datetime) def test_mark_processing_clears_error(self): """mark_processing 清除错误信息.""" job = TTSJob.create(user_id="u1", input_text="hi") job.error_message = "previous error" job.mark_processing() assert job.error_message == "" def test_mark_completed_sets_fields(self): """mark_completed 设置所有输出字段.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed( "https://example.com/out.mp3", output_audio_key="audio/001.mp3", duration=10.5, file_size=256000, ) assert job.output_audio_url == "https://example.com/out.mp3" assert job.output_audio_key == "audio/001.mp3" assert job.duration == 10.5 assert job.file_size == 256000 def test_mark_completed_sets_completed_at(self): """mark_completed 设置completed_at.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() assert job.completed_at is None job.mark_completed("https://a.mp3") assert job.completed_at is not None assert isinstance(job.completed_at, datetime) def test_mark_completed_clears_error(self): """mark_completed 清除错误信息.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.error_message = "temp error" job.mark_completed("https://a.mp3") assert job.error_message == "" def test_mark_completed_empty_url(self): """空output_audio_url抛错.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() with pytest.raises(ValueError, match="output_audio_url"): job.mark_completed("") def test_mark_completed_whitespace_url(self): """纯空白URL抛错.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() with pytest.raises(ValueError): job.mark_completed(" ") def test_mark_completed_strips_url(self): """URL去除空白.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed(" https://a.mp3 ") assert job.output_audio_url == "https://a.mp3" def test_mark_failed_sets_error(self): """mark_failed 设置错误信息.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("连接超时") assert job.error_message == "连接超时" def test_mark_failed_from_pending(self): """从pending直接失败.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_failed("验证失败") assert job.status == TTSJobStatus.FAILED assert job.error_message == "验证失败" def test_mark_cancelled_from_pending(self): """从pending取消.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_cancelled() assert job.status == TTSJobStatus.CANCELLED def test_mark_cancelled_from_processing(self): """从processing取消.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_cancelled() assert job.status == TTSJobStatus.CANCELLED # ── 重试逻辑测试 ────────────────────────────────────────────────────────────── class TestPrepareRetry: """prepare_retry 重试逻辑测试.""" def test_prepare_retry_basic(self): """基础重试成功.""" job = TTSJob.create(user_id="u1", input_text="hi", max_retries=3) job.mark_processing() job.mark_failed("err") job.prepare_retry() assert job.status == TTSJobStatus.PENDING assert job.retry_count == 1 def test_prepare_retry_clears_error(self): """重试清除错误信息.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("big error") job.prepare_retry() assert job.error_message == "" def test_prepare_retry_clears_started_at(self): """重试清除started_at.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() assert job.started_at is not None job.mark_failed("err") job.prepare_retry() assert job.started_at is None def test_prepare_retry_clears_completed_at(self): """重试清除completed_at.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("err") job.completed_at = datetime.now(UTC) # 模拟设置过 job.prepare_retry() assert job.completed_at is None def test_prepare_retry_not_failed(self): """非failed状态不可重试.""" job = TTSJob.create(user_id="u1", input_text="hi") with pytest.raises(ValueError, match="不可重试"): job.prepare_retry() def test_prepare_retry_exceeds_max(self): """超过最大重试次数不可重试.""" job = TTSJob.create(user_id="u1", input_text="hi", max_retries=1) job.mark_processing() job.mark_failed("e1") job.prepare_retry() # retry_count=1 job.mark_processing() job.mark_failed("e2") with pytest.raises(ValueError, match="不可重试"): job.prepare_retry() def test_prepare_retry_error_has_details(self): """错误信息包含详细状态.""" job = TTSJob.create(user_id="u1", input_text="hi") with pytest.raises(ValueError) as exc_info: job.prepare_retry() msg = str(exc_info.value) assert "pending" in msg assert "retry_count" in msg assert "max_retries" in msg # ── 序列化测试 ──────────────────────────────────────────────────────────────── class TestToDict: """to_dict 序列化测试.""" def test_to_dict_keys(self): """序列化字典包含所有预期字段.""" job = TTSJob.create(user_id="u1", input_text="测试文本") d = job.to_dict() expected_keys = { "id", "user_id", "project_id", "voice_clone_profile_id", "status", "input_text", "voice_id", "voice_model", "output_audio_url", "output_audio_key", "duration", "file_size", "sample_rate", "format", "error_message", "retry_count", "max_retries", "is_retryable", "is_completed", "metadata", "started_at", "completed_at", "created_at", "updated_at", } assert set(d.keys()) == expected_keys def test_to_dict_values(self): """序列化值正确.""" job = TTSJob.create( user_id="user123", input_text="你好世界", voice_id="voice_001", project_id="proj_001", sample_rate=44100, format="wav", max_retries=5, metadata={"source": "api"}, ) d = job.to_dict() assert d["user_id"] == "user123" assert d["input_text"] == "你好世界" assert d["voice_id"] == "voice_001" assert d["project_id"] == "proj_001" assert d["status"] == "pending" assert d["sample_rate"] == 44100 assert d["format"] == "wav" assert d["retry_count"] == 0 assert d["max_retries"] == 5 assert d["is_retryable"] is False assert d["is_completed"] is False assert d["metadata"] == {"source": "api"} def test_to_dict_completed_status(self): """completed状态下序列化正确.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_completed("https://example.com/out.mp3", duration=5.5, file_size=128000) d = job.to_dict() assert d["status"] == "completed" assert d["output_audio_url"] == "https://example.com/out.mp3" assert d["duration"] == 5.5 assert d["file_size"] == 128000 assert d["is_completed"] is True assert d["is_retryable"] is False assert d["started_at"] is not None assert d["completed_at"] is not None def test_to_dict_failed_status(self): """failed状态下序列化正确.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() job.mark_failed("超时错误") d = job.to_dict() assert d["status"] == "failed" assert d["error_message"] == "超时错误" assert d["is_retryable"] is True assert d["is_completed"] is False def test_to_dict_nullable_times(self): """空时间字段为None.""" job = TTSJob.create(user_id="u1", input_text="hi") d = job.to_dict() assert d["started_at"] is None assert d["completed_at"] is None def test_to_dict_datetime_format(self): """时间字段是ISO格式字符串.""" job = TTSJob.create(user_id="u1", input_text="hi") job.mark_processing() d = job.to_dict() datetime.fromisoformat(d["created_at"]) datetime.fromisoformat(d["updated_at"]) datetime.fromisoformat(d["started_at"])