"""job 单测. domain 层统一异步任务纯逻辑模块,0 外部依赖。 覆盖:枚举、create工厂、状态机、进度更新、重试机制、序列化。 """ from __future__ import annotations from packages.domain.job import TERMINAL_STATUSES, Job, JobStatus, JobType class TestJobType: """JobType 枚举测试.""" def test_six_types(self): """六种任务类型.""" assert len(JobType) == 6 def test_video_compose(self): assert JobType.VIDEO_COMPOSE == "video_compose" def test_render_edit_plan(self): assert JobType.RENDER_EDIT_PLAN == "render_edit_plan" def test_asset_ingest(self): assert JobType.ASSET_INGEST == "asset_ingest" def test_classification(self): assert JobType.CLASSIFICATION == "classification" def test_voice_extraction(self): assert JobType.VOICE_EXTRACTION == "voice_extraction" def test_generation(self): assert JobType.GENERATION == "generation" class TestJobStatus: """JobStatus 枚举测试.""" def test_five_statuses(self): assert len(JobStatus) == 5 def test_pending(self): assert JobStatus.PENDING == "pending" def test_running(self): assert JobStatus.RUNNING == "running" def test_success(self): assert JobStatus.SUCCESS == "success" def test_failed(self): assert JobStatus.FAILED == "failed" def test_cancelled(self): assert JobStatus.CANCELLED == "cancelled" 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 class TestJobCreate: """Job.create 工厂测试.""" def test_create_minimal(self): """最简创建.""" job = Job.create(project_id="proj1", job_type=JobType.VIDEO_COMPOSE) assert job.project_id == "proj1" assert job.job_type == JobType.VIDEO_COMPOSE assert job.status == JobStatus.PENDING assert job.progress == 0.0 assert job.current_stage == "" assert job.payload == {} assert job.result == {} assert job.error_message == "" assert job.retry_count == 0 assert job.max_retries == 3 assert job.source_id == "" assert job.created_by_user_id == "" assert job.started_at is None assert job.completed_at is None assert isinstance(job.id, str) assert len(job.id) > 0 def test_create_with_string_job_type(self): """用字符串传 job_type.""" job = Job.create(project_id="p1", job_type="video_compose") assert job.job_type == JobType.VIDEO_COMPOSE def test_create_invalid_job_type_string(self): """无效的 job_type 字符串.""" try: Job.create(project_id="p1", job_type="invalid_type") raise AssertionError("unexpected success") except ValueError as e: assert "不支持的任务类型" in str(e) def test_create_full(self): """带全部字段.""" job = Job.create( project_id=" proj1 ", job_type=JobType.CLASSIFICATION, payload={"asset_id": "a1"}, source_id=" src1 ", created_by_user_id=" user1 ", max_retries=5, ) assert job.project_id == "proj1" # strip assert job.job_type == JobType.CLASSIFICATION assert job.payload == {"asset_id": "a1"} assert job.source_id == "src1" # strip assert job.created_by_user_id == "user1" # strip assert job.max_retries == 5 def test_create_empty_project_id(self): """空 project_id 无效.""" try: Job.create(project_id="", job_type=JobType.VIDEO_COMPOSE) raise AssertionError("unexpected success") except ValueError as e: assert "project_id" in str(e) def test_create_whitespace_project_id(self): """空白 project_id 无效.""" try: Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE) raise AssertionError("unexpected success") except ValueError as e: assert "project_id" in str(e) def test_create_payload_none_defaults_empty(self): """payload=None 默认为空 dict.""" job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, payload=None) assert job.payload == {} def test_create_unique_id(self): """不同任务 id 不同.""" j1 = Job.create("p", JobType.VIDEO_COMPOSE) j2 = Job.create("p", JobType.VIDEO_COMPOSE) assert j1.id != j2.id def test_create_has_timestamps(self): """有创建和更新时间.""" job = Job.create("p", JobType.VIDEO_COMPOSE) assert job.created_at is not None assert job.updated_at is not None class TestJobIsTerminal: """is_terminal 属性测试.""" def _make_job(self): return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) def test_pending_not_terminal(self): job = self._make_job() assert job.is_terminal is False def test_running_not_terminal(self): job = self._make_job() job.mark_running() assert job.is_terminal is False def test_success_is_terminal(self): job = self._make_job() job.mark_running() job.mark_success() assert job.is_terminal is True def test_failed_is_terminal(self): job = self._make_job() job.mark_running() job.mark_failed("error") assert job.is_terminal is True def test_cancelled_is_terminal(self): job = self._make_job() job.mark_cancelled() assert job.is_terminal is True class TestJobStatusTransitions: """状态转换测试.""" def _make_job(self): return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) def test_pending_to_running(self): """pending → running.""" job = self._make_job() job.transition_to(JobStatus.RUNNING) assert job.status == JobStatus.RUNNING def test_pending_to_success(self): """pending → success(瞬时任务).""" job = self._make_job() job.transition_to(JobStatus.SUCCESS) assert job.status == JobStatus.SUCCESS def test_pending_to_cancelled(self): """pending → cancelled.""" job = self._make_job() job.transition_to(JobStatus.CANCELLED) assert job.status == JobStatus.CANCELLED def test_running_to_success(self): """running → success.""" job = self._make_job() job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.SUCCESS) assert job.status == JobStatus.SUCCESS def test_running_to_failed(self): """running → failed.""" job = self._make_job() job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.FAILED) assert job.status == JobStatus.FAILED def test_running_to_cancelled(self): """running → cancelled.""" job = self._make_job() job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.CANCELLED) assert job.status == JobStatus.CANCELLED def test_failed_to_pending_retry(self): """failed → pending(重试).""" job = self._make_job() job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.FAILED) job.transition_to(JobStatus.PENDING) assert job.status == JobStatus.PENDING def test_cannot_success_to_running(self): """success 不能回 running.""" job = self._make_job() job.mark_running() job.mark_success() try: job.transition_to(JobStatus.RUNNING) raise AssertionError("unexpected success") except ValueError as e: assert "非法状态转换" in str(e) def test_cannot_pending_to_failed_directly(self): """pending 不能直接到 failed(必须经过 running).""" job = self._make_job() try: job.transition_to(JobStatus.FAILED) raise AssertionError("unexpected success") except ValueError as e: assert "非法状态转换" in str(e) def test_transition_with_string(self): """用字符串传状态.""" job = self._make_job() job.transition_to("running") assert job.status == JobStatus.RUNNING def test_transition_invalid_string(self): """无效状态字符串.""" job = self._make_job() try: job.transition_to("invalid") raise AssertionError("unexpected success") except ValueError as e: assert "无效状态" in str(e) def test_transition_sets_started_at(self): """第一次到 running 设置 started_at.""" job = self._make_job() assert job.started_at is None job.transition_to(JobStatus.RUNNING) assert job.started_at is not None def test_transition_sets_completed_at_on_success(self): """success 设置 completed_at.""" job = self._make_job() job.mark_running() assert job.completed_at is None job.mark_success() assert job.completed_at is not None def test_transition_sets_completed_at_on_failed(self): """failed 设置 completed_at.""" job = self._make_job() job.mark_running() job.mark_failed("err") assert job.completed_at is not None class TestJobMarkMethods: """便捷标记方法测试.""" def _make_job(self): return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) def test_mark_running(self): job = self._make_job() job.mark_running() assert job.status == JobStatus.RUNNING def test_mark_running_with_stage(self): job = self._make_job() job.mark_running(stage="正在合成视频") assert job.status == JobStatus.RUNNING assert job.current_stage == "正在合成视频" def test_mark_success(self): job = self._make_job() job.mark_running() job.mark_success() assert job.status == JobStatus.SUCCESS assert job.progress == 100.0 assert job.current_stage == "完成" def test_mark_success_with_result(self): job = self._make_job() job.mark_running() job.mark_success(result={"video_url": "http://x/v.mp4"}) assert job.result == {"video_url": "http://x/v.mp4"} def test_mark_failed(self): job = self._make_job() job.mark_running() job.mark_failed("网络超时") assert job.status == JobStatus.FAILED assert job.error_message == "网络超时" assert job.current_stage == "失败" def test_mark_cancelled(self): job = self._make_job() job.mark_cancelled() assert job.status == JobStatus.CANCELLED assert job.current_stage == "已取消" class TestJobProgress: """进度更新测试.""" def _make_job(self): return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) def test_update_progress(self): """正常更新进度.""" job = self._make_job() job.update_progress(50.0) assert job.progress == 50.0 def test_update_progress_with_stage(self): """更新进度同时更新阶段.""" job = self._make_job() job.update_progress(30.0, stage="合成中") assert job.progress == 30.0 assert job.current_stage == "合成中" def test_update_progress_zero(self): """0% 合法.""" job = self._make_job() job.update_progress(0.0) assert job.progress == 0.0 def test_update_progress_100(self): """100% 合法.""" job = self._make_job() job.update_progress(100.0) assert job.progress == 100.0 def test_update_progress_negative(self): """负进度无效.""" job = self._make_job() try: job.update_progress(-1.0) raise AssertionError("unexpected success") except ValueError as e: assert "进度" in str(e) def test_update_progress_over_100(self): """超过100%无效.""" job = self._make_job() try: job.update_progress(101.0) raise AssertionError("unexpected success") except ValueError as e: assert "进度" in str(e) def test_update_progress_updates_timestamp(self): """更新进度时更新 updated_at.""" job = self._make_job() old = job.updated_at job.update_progress(50.0) assert job.updated_at >= old class TestJobRetry: """重试机制测试.""" def _make_failed_job(self, max_retries=3): job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=max_retries) job.mark_running() job.mark_failed("error") return job def test_is_retryable_true(self): """失败且未超过重试次数时可重试.""" job = self._make_failed_job(max_retries=3) assert job.is_retryable is True def test_is_retryable_false_not_failed(self): """非失败状态不可重试.""" job = Job.create("p", JobType.VIDEO_COMPOSE) assert job.is_retryable is False def test_is_retryable_false_exceeded(self): """超过重试次数不可重试.""" job = self._make_failed_job(max_retries=0) assert job.is_retryable is False def test_prepare_retry(self): """准备重试.""" job = self._make_failed_job(max_retries=3) 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_prepare_retry_multiple_times(self): """多次重试.""" job = self._make_failed_job(max_retries=3) job.prepare_retry() job.mark_running() job.mark_failed("err2") assert job.retry_count == 1 job.prepare_retry() assert job.retry_count == 2 assert "第 2 次重试" in job.current_stage def test_prepare_retry_not_retryable(self): """不可重试时报错.""" job = self._make_failed_job(max_retries=0) try: job.prepare_retry() raise AssertionError("unexpected success") except ValueError as e: assert "不可重试" in str(e) class TestJobToDict: """to_dict 序列化测试.""" def test_to_dict_keys(self): """包含所有必要字段.""" job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) d = job.to_dict() assert isinstance(d, dict) keys = [ "id", "project_id", "job_type", "status", "progress", "payload", "result", "error_message", "retry_count", "max_retries", "source_id", "is_retryable", "created_at", "updated_at", ] for k in keys: assert k in d, f"缺少字段: {k}" def test_to_dict_enums_as_strings(self): """枚举值序列化为字符串.""" job = Job.create(project_id="p1", job_type=JobType.CLASSIFICATION) d = job.to_dict() assert d["job_type"] == "classification" assert d["status"] == "pending" def test_to_dict_none_timestamps(self): """None 时间戳序列化为 None.""" 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_to_dict_after_success(self): """成功后的序列化.""" job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) job.mark_running("test") job.mark_success({"url": "http://x"}) d = job.to_dict() assert d["status"] == "success" assert d["progress"] == 100.0 assert d["result"] == {"url": "http://x"} assert d["started_at"] is not None assert d["completed_at"] is not None assert d["is_retryable"] is False