diff --git a/tests/unit/test_tts_job.py b/tests/unit/test_tts_job.py old mode 100644 new mode 100755 index 9d23eefc8..c2ab30111 --- a/tests/unit/test_tts_job.py +++ b/tests/unit/test_tts_job.py @@ -1,344 +1,710 @@ -"""TTSJob 领域模型单元测试 — Phase 3 CosyVoice 集成.""" +"""TTSJob 领域模型单元测试.""" from __future__ import annotations +from datetime import datetime, timezone +from time import sleep + import pytest -from packages.domain.tts_job import TTSJob, TTSJobStatus +from packages.domain.tts_job import ( + TERMINAL_STATUSES, + 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: - """测试状态相关属性和方法。""" + """TTSJobStatus 枚举测试.""" - def test_initial_status_is_pending(self) -> None: - """初始状态应为 PENDING。""" - job = TTSJob.create(user_id="user_001", input_text="测试") + 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_is_terminal_pending(self) -> None: - """PENDING 不是终态。""" - job = TTSJob.create(user_id="user_001", input_text="测试") - assert not job.is_terminal + 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_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_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_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_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_is_terminal_cancelled(self) -> None: - """CANCELLED 是终态。""" - job = TTSJob.create(user_id="user_001", input_text="测试") - job.mark_cancelled() - assert job.is_terminal + 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_is_retryable_not_failed(self) -> None: - """非 FAILED 状态不可重试。""" - job = TTSJob.create(user_id="user_001", input_text="测试") - assert not job.is_retryable + def test_create_default_sample_rate(self): + """默认采样率22050.""" + job = TTSJob.create(user_id="u1", input_text="hi") + assert job.sample_rate == 22050 - 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 + @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_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_create_default_format(self): + """默认格式mp3.""" + job = TTSJob.create(user_id="u1", input_text="hi") + assert job.format == "mp3" - 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_create_invalid_format(self): + """不支持的格式抛错.""" + with pytest.raises(ValueError, match="不支持的输出格式"): + TTSJob.create(user_id="u1", input_text="hi", format="aac") - 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 + 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 -class TestTTSJobTransitions: - """测试状态转换。""" + 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_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_create_strips_text(self): + """input_text去除首尾空白.""" + job = TTSJob.create(user_id="u1", input_text=" 你好世界 ") + assert job.input_text == "你好世界" - 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_create_strips_user_id(self): + """user_id去除空白.""" + job = TTSJob.create(user_id=" user123 ", input_text="hi") + assert job.user_id == "user123" - 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_create_empty_user_id(self): + """空user_id抛错.""" + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id="", input_text="hi") - 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_create_whitespace_user_id(self): + """纯空白user_id抛错.""" + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id=" ", input_text="hi") - 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_create_empty_input_text(self): + """空input_text抛错.""" + with pytest.raises(ValueError, match="input_text"): + TTSJob.create(user_id="u1", input_text="") - 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_create_whitespace_input_text(self): + """纯空白input_text抛错.""" + with pytest.raises(ValueError, match="input_text"): + TTSJob.create(user_id="u1", input_text=" \n ") - 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_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_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_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_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 == "" + 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_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_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_prepare_retry_over_limit_raises(self) -> None: - """超过重试上限重试应抛出 ValueError。""" - job = TTSJob.create(user_id="user_001", input_text="测试", max_retries=1) + 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() - job.mark_failed("第一次失败") - job.prepare_retry() + 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_failed("第二次失败") + job.mark_completed("https://example.com/audio.mp3") + assert job.is_terminal is True - with pytest.raises(ValueError, match="不可重试"): - job.prepare_retry() + 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 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() +class TestTransitionTo: + """transition_to 状态转换测试.""" - 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_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_to_dict_after_completion(self) -> None: - """任务完成后 to_dict 应反映最新状态。""" - job = TTSJob.create(user_id="user_001", input_text="测试") + 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( - output_audio_url="https://example.com/audio.mp3", + "https://example.com/out.mp3", + output_audio_key="audio/001.mp3", duration=10.5, - file_size=204800, + 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 - result = job.to_dict() + 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) - 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 + 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(timezone.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"])