Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7580c96f90 |
@@ -0,0 +1,459 @@
|
||||
"""Job 领域模型单测 — 状态机 + 实体方法全覆盖."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.job import (
|
||||
Job,
|
||||
JobStatus,
|
||||
JobType,
|
||||
TERMINAL_STATUSES,
|
||||
)
|
||||
|
||||
|
||||
# ── 枚举常量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJobType:
|
||||
def test_all_types_exist(self):
|
||||
assert JobType.VIDEO_COMPOSE == "video_compose"
|
||||
assert JobType.RENDER_EDIT_PLAN == "render_edit_plan"
|
||||
assert JobType.ASSET_INGEST == "asset_ingest"
|
||||
assert JobType.CLASSIFICATION == "classification"
|
||||
assert JobType.VOICE_EXTRACTION == "voice_extraction"
|
||||
assert JobType.GENERATION == "generation"
|
||||
|
||||
def test_from_string(self):
|
||||
assert JobType("video_compose") == JobType.VIDEO_COMPOSE
|
||||
assert JobType("render_edit_plan") == JobType.RENDER_EDIT_PLAN
|
||||
|
||||
def test_invalid_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
JobType("invalid_type")
|
||||
|
||||
|
||||
class TestJobStatus:
|
||||
def test_all_statuses_exist(self):
|
||||
assert JobStatus.PENDING == "pending"
|
||||
assert JobStatus.RUNNING == "running"
|
||||
assert JobStatus.SUCCESS == "success"
|
||||
assert JobStatus.FAILED == "failed"
|
||||
assert JobStatus.CANCELLED == "cancelled"
|
||||
|
||||
def test_from_string(self):
|
||||
assert JobStatus("pending") == JobStatus.PENDING
|
||||
assert JobStatus("running") == JobStatus.RUNNING
|
||||
|
||||
|
||||
class TestTerminalStatuses:
|
||||
def test_success_is_terminal(self):
|
||||
assert JobStatus.SUCCESS in TERMINAL_STATUSES
|
||||
|
||||
def test_failed_is_terminal(self):
|
||||
assert JobStatus.FAILED in TERMINAL_STATUSES
|
||||
|
||||
def test_cancelled_is_terminal(self):
|
||||
assert JobStatus.CANCELLED in TERMINAL_STATUSES
|
||||
|
||||
def test_pending_not_terminal(self):
|
||||
assert JobStatus.PENDING not in TERMINAL_STATUSES
|
||||
|
||||
def test_running_not_terminal(self):
|
||||
assert JobStatus.RUNNING not in TERMINAL_STATUSES
|
||||
|
||||
|
||||
# ── Job.create ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJobCreate:
|
||||
def test_basic_creation(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.id # 自动生成
|
||||
assert job.project_id == "proj_1"
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.progress == 0.0
|
||||
assert job.retry_count == 0
|
||||
assert job.max_retries == 3
|
||||
assert job.payload == {}
|
||||
assert job.result == {}
|
||||
|
||||
def test_job_type_string_conversion(self):
|
||||
job = Job.create(project_id="proj_1", job_type="video_compose")
|
||||
assert job.job_type == JobType.VIDEO_COMPOSE
|
||||
|
||||
def test_invalid_job_type_raises(self):
|
||||
with pytest.raises(ValueError, match="不支持的任务类型"):
|
||||
Job.create(project_id="proj_1", job_type="invalid")
|
||||
|
||||
def test_empty_project_id_raises(self):
|
||||
with pytest.raises(ValueError, match="project_id 不能为空"):
|
||||
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
|
||||
|
||||
def test_project_id_stripped(self):
|
||||
job = Job.create(project_id=" proj_1 ", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.project_id == "proj_1"
|
||||
|
||||
def test_with_payload(self):
|
||||
payload = {"video_url": "http://example.com/v.mp4"}
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, payload=payload)
|
||||
assert job.payload == payload
|
||||
|
||||
def test_payload_none_defaults_empty_dict(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, payload=None)
|
||||
assert job.payload == {}
|
||||
|
||||
def test_with_source_id(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, source_id="plan_123")
|
||||
assert job.source_id == "plan_123"
|
||||
|
||||
def test_source_id_stripped(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, source_id=" src ")
|
||||
assert job.source_id == "src"
|
||||
|
||||
def test_with_created_by(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user_1")
|
||||
assert job.created_by_user_id == "user_1"
|
||||
|
||||
def test_custom_max_retries(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, max_retries=5)
|
||||
assert job.max_retries == 5
|
||||
|
||||
def test_created_at_auto_set(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.created_at is not None
|
||||
assert job.updated_at is not None
|
||||
|
||||
def test_started_at_none_initially(self):
|
||||
job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
|
||||
|
||||
# ── 属性判断 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJobProperties:
|
||||
def test_is_terminal_pending_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert not job.is_terminal
|
||||
|
||||
def test_is_terminal_running_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert not job.is_terminal
|
||||
|
||||
def test_is_terminal_success_true(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_terminal_failed_true(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_terminal_cancelled_true(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.is_terminal
|
||||
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.is_retryable
|
||||
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
job.retry_count = 3
|
||||
assert not job.is_retryable
|
||||
|
||||
def test_is_retryable_pending_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert not job.is_retryable
|
||||
|
||||
def test_is_retryable_success_false(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert not job.is_retryable
|
||||
|
||||
|
||||
# ── 状态转换 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionTo:
|
||||
def test_pending_to_running(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.status == JobStatus.RUNNING
|
||||
|
||||
def test_pending_to_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
|
||||
def test_pending_to_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
|
||||
def test_running_to_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
|
||||
def test_running_to_failed(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.status == JobStatus.FAILED
|
||||
|
||||
def test_running_to_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.CANCELLED)
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
|
||||
def test_failed_to_pending_retry(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
job.transition_to(JobStatus.PENDING)
|
||||
assert job.status == JobStatus.PENDING
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
# success → running 是非法的
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
|
||||
def test_string_status_conversion(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to("running")
|
||||
assert job.status == JobStatus.RUNNING
|
||||
|
||||
def test_invalid_string_status_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
job.transition_to("invalid_status")
|
||||
|
||||
def test_transition_updates_updated_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
old_updated = job.updated_at
|
||||
import time; time.sleep(0.001)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.updated_at >= old_updated
|
||||
|
||||
def test_running_sets_started_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
assert job.started_at is None
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.started_at is not None
|
||||
|
||||
def test_success_sets_completed_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.completed_at is None
|
||||
job.transition_to(JobStatus.SUCCESS)
|
||||
assert job.completed_at is not None
|
||||
|
||||
def test_failed_sets_completed_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.transition_to(JobStatus.FAILED)
|
||||
assert job.completed_at is not None
|
||||
|
||||
|
||||
# ── 便捷方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMarkMethods:
|
||||
def test_mark_running(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running(stage="初始化")
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.current_stage == "初始化"
|
||||
|
||||
def test_mark_running_no_stage(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
assert job.status == JobStatus.RUNNING
|
||||
assert job.current_stage == ""
|
||||
|
||||
def test_mark_success(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
result = {"output_url": "http://example.com/out.mp4"}
|
||||
job.mark_success(result=result)
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.progress == 100.0
|
||||
assert job.current_stage == "完成"
|
||||
assert job.result == result
|
||||
|
||||
def test_mark_success_no_result(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_success()
|
||||
assert job.status == JobStatus.SUCCESS
|
||||
assert job.result == {} # 保持默认空字典
|
||||
|
||||
def test_mark_failed(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_failed("网络超时")
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert job.error_message == "网络超时"
|
||||
assert job.current_stage == "失败"
|
||||
|
||||
def test_mark_cancelled(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_cancelled()
|
||||
assert job.status == JobStatus.CANCELLED
|
||||
assert job.current_stage == "已取消"
|
||||
|
||||
|
||||
# ── 进度更新 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateProgress:
|
||||
def test_normal_progress(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(50.0, stage="渲染中")
|
||||
assert job.progress == 50.0
|
||||
assert job.current_stage == "渲染中"
|
||||
|
||||
def test_progress_zero(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(0.0)
|
||||
assert job.progress == 0.0
|
||||
|
||||
def test_progress_100(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(100.0)
|
||||
assert job.progress == 100.0
|
||||
|
||||
def test_progress_negative_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
|
||||
job.update_progress(-1.0)
|
||||
|
||||
def test_progress_over_100_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
|
||||
job.update_progress(100.1)
|
||||
|
||||
def test_progress_without_stage(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.update_progress(30.0)
|
||||
assert job.progress == 30.0
|
||||
|
||||
def test_progress_updates_updated_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
old_updated = job.updated_at
|
||||
import time; time.sleep(0.001)
|
||||
job.update_progress(50.0)
|
||||
assert job.updated_at > old_updated
|
||||
|
||||
|
||||
# ── 重试 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPrepareRetry:
|
||||
def test_normal_retry(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_failed("超时")
|
||||
job.prepare_retry()
|
||||
assert job.status == JobStatus.PENDING
|
||||
assert job.retry_count == 1
|
||||
assert job.progress == 0.0
|
||||
assert "第 1 次重试" in job.current_stage
|
||||
assert job.error_message == ""
|
||||
assert job.started_at is None
|
||||
assert job.completed_at is None
|
||||
assert job.celery_task_id == ""
|
||||
|
||||
def test_retry_increments_count(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_failed("超时")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 1
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_failed("又超时了")
|
||||
job.prepare_retry()
|
||||
assert job.retry_count == 2
|
||||
|
||||
def test_retry_exceeds_limit_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_failed("超时")
|
||||
job.prepare_retry() # 第1次重试,ok
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_failed("又超时了")
|
||||
# retry_count=1, max_retries=1 → 不能再重试
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_retry_from_success_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
job.mark_success()
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
def test_retry_from_pending_raises(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
job.prepare_retry()
|
||||
|
||||
|
||||
# ── 序列化 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToDict:
|
||||
def test_basic_fields(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, source_id="src1")
|
||||
d = job.to_dict()
|
||||
assert d["id"] == job.id
|
||||
assert d["project_id"] == "p1"
|
||||
assert d["job_type"] == "video_compose"
|
||||
assert d["status"] == "pending"
|
||||
assert d["progress"] == 0.0
|
||||
assert d["source_id"] == "src1"
|
||||
assert d["retry_count"] == 0
|
||||
assert d["max_retries"] == 3
|
||||
|
||||
def test_is_retryable_in_output(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
d = job.to_dict()
|
||||
assert "is_retryable" in d
|
||||
assert d["is_retryable"] is False
|
||||
|
||||
def test_datetime_fields_are_strings(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
d = job.to_dict()
|
||||
assert isinstance(d["created_at"], str)
|
||||
assert isinstance(d["updated_at"], str)
|
||||
|
||||
def test_started_at_none_when_not_started(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
d = job.to_dict()
|
||||
assert d["started_at"] is None
|
||||
assert d["completed_at"] is None
|
||||
|
||||
def test_started_at_present_after_running(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
job.mark_running()
|
||||
d = job.to_dict()
|
||||
assert d["started_at"] is not None
|
||||
assert isinstance(d["started_at"], str)
|
||||
Reference in New Issue
Block a user