Compare commits

...

2 Commits

Author SHA1 Message Date
CI Bot 5f3308a4a1 style: auto-format with black + isort + prettier [skip ci-format-check] 2026-07-28 08:40:11 +00:00
xiaoxia 4cd8cd74af test(wave160): job领域模型 +69测
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Build Production API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Worker Image (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m39s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Waiting to run
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m54s
CI/CD Pipeline / Integration Tests (pull_request) Waiting to run
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m39s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m8s
AI Code Review / AI Code Review (pull_request) Successful in 3m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m1s
2026-07-28 16:06:07 +08:00
5 changed files with 523 additions and 4 deletions
@@ -6,7 +6,6 @@
from __future__ import annotations
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -13,7 +13,6 @@
from __future__ import annotations
from packages.domain.speed_config import (
DEFAULT_SPEED,
SpeedConfig,
+523
View File
@@ -0,0 +1,523 @@
"""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
@@ -6,7 +6,6 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
from __future__ import annotations
import dataclasses
from unittest.mock import MagicMock
import pytest
-1
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import dataclasses
import pytest
from packages.domain.transition_presets import (