"""Job 领域模型单测 — 状态机 + 实体方法全覆盖.""" from __future__ import annotations import pytest from packages.domain.job import ( TERMINAL_STATUSES, Job, JobStatus, JobType, ) # ── 枚举常量 ──────────────────────────────────────────────────────────────── 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)