fa8ab58fc0
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m14s
AI Code Review / AI Code Review (pull_request) Successful in 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m10s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 24s
- TTSJobStatus 枚举: 5种状态值、字符串比较、从字符串构建 - TERMINAL_STATUSES: 3个终态验证 - create 工厂方法: 基础创建、voice_id/project_id/voice_clone_profile_id - 采样率/格式(format参数化mp3/wav/pcm)、max_retries、metadata - 参数校验: 空user_id/空input_text/超长文本(10000)/无效格式/纯空白 - 属性: is_terminal(5种)、is_retryable(5种)、is_completed(4种) - transition_to: 合法转换(8条)、非法转换(4条)、字符串输入、updated_at更新 - mark_*方法: mark_processing(started_at)、mark_completed(全部字段)、mark_failed、mark_cancelled - prepare_retry: 正常重试、清除error/started_at/completed_at、超上限不可重试 - to_dict: 字段完整性、值正确性、completed/failed状态、空时间字段、ISO时间格式
711 lines
26 KiB
Python
Executable File
711 lines
26 KiB
Python
Executable File
"""TTSJob 领域模型单元测试."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import 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(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"])
|