From 4cd8cd74af2772b273a4a8c27ab2451605334241 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 15:25:29 +0800 Subject: [PATCH] =?UTF-8?q?test(wave160):=20job=E9=A2=86=E5=9F=9F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20+69=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/domain/test_job.py | 523 ++++++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 tests/unit/domain/test_job.py diff --git a/tests/unit/domain/test_job.py b/tests/unit/domain/test_job.py new file mode 100644 index 000000000..cf0e2d918 --- /dev/null +++ b/tests/unit/domain/test_job.py @@ -0,0 +1,523 @@ +"""job 单测. + +domain 层统一异步任务纯逻辑模块,0 外部依赖。 +覆盖:枚举、create工厂、状态机、进度更新、重试机制、序列化。 +""" + +from __future__ import annotations + +from packages.domain.job import Job, JobStatus, JobType, TERMINAL_STATUSES + + +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 -- 2.54.0