"""GenerationTask 领域模型状态机单元测试. 覆盖: - 初始状态为 pending - mark_processing: pending → running - mark_completed: running → completed - mark_failed: pending/running → failed - mark_cancelled: pending/running → cancelled - mark_pending_from_failed: failed → pending(重试) - 非法状态转换抛出 ValueError - is_terminal / is_completed / is_failed / is_running 属性 - 状态转换时的时间戳设置 """ from __future__ import annotations import pytest from packages.domain.generation_task import ( TERMINAL_STATUSES, GenerationTask, GenerationTaskStatus, ) def _make_task(**overrides) -> GenerationTask: """创建一个测试用的 GenerationTask。""" defaults = dict( id="task-test-001", project_id="proj-1", asset_library_id="lib-1", ) defaults.update(overrides) return GenerationTask(**defaults) # ── 初始状态 ────────────────────────────────────────────────────────────────── class TestInitialState: """测试初始状态。""" def test_default_status_is_pending(self) -> None: """新创建的任务默认状态为 pending。""" task = _make_task() assert task.status == GenerationTaskStatus.PENDING assert task.progress == 0.0 assert task.result_count == 0 assert task.error_message == "" assert task.started_at is None assert task.completed_at is None def test_create_factory_returns_pending(self) -> None: """GenerationTask.create() 返回的任务状态为 pending。""" task = GenerationTask.create( project_id="proj-1", asset_library_id="lib-1", created_by_user_id="user-1", ) assert task.status == GenerationTaskStatus.PENDING assert task.progress == 0.0 assert task.result_count == 0 def test_is_not_terminal_initially(self) -> None: """初始状态不是终态。""" task = _make_task() assert not task.is_terminal assert not task.is_completed assert not task.is_failed assert not task.is_running def test_terminal_statuses_constant(self) -> None: """终态集合包含 completed / failed / cancelled。""" assert GenerationTaskStatus.COMPLETED in TERMINAL_STATUSES assert GenerationTaskStatus.FAILED in TERMINAL_STATUSES assert GenerationTaskStatus.CANCELLED in TERMINAL_STATUSES assert GenerationTaskStatus.PENDING not in TERMINAL_STATUSES assert GenerationTaskStatus.RUNNING not in TERMINAL_STATUSES # ── mark_processing ────────────────────────────────────────────────────────── class TestMarkProcessing: """测试 pending → running 转换。""" def test_pending_to_running_success(self) -> None: """pending 状态的任务可以标记为 running。""" task = _make_task() task.mark_processing() assert task.status == GenerationTaskStatus.RUNNING assert task.is_running assert task.started_at is not None assert task.error_message == "" def test_started_at_is_set(self) -> None: """mark_processing 设置 started_at 时间戳。""" task = _make_task() assert task.started_at is None task.mark_processing() assert task.started_at is not None def test_error_message_cleared(self) -> None: """mark_processing 清除 error_message(如果有的话)。""" task = _make_task() # 注意:pending 状态通常没有 error_message,这里验证确保被清除 task.error_message = "some old error" # 直接设置状态绕过校验(模拟异常场景) task.status = GenerationTaskStatus.PENDING task.mark_processing() assert task.error_message == "" def test_running_to_running_raises(self) -> None: """running 状态不能再次 mark_processing。""" task = _make_task() task.mark_processing() with pytest.raises(ValueError, match="非法状态转换"): task.mark_processing() def test_completed_to_running_raises(self) -> None: """completed 状态不能回到 running。""" task = _make_task() task.mark_processing() task.mark_completed() with pytest.raises(ValueError, match="非法状态转换"): task.mark_processing() def test_failed_to_running_raises(self) -> None: """failed 状态不能直接到 running(应先重置为 pending)。""" task = _make_task() task.mark_processing() task.mark_failed("some error") with pytest.raises(ValueError, match="非法状态转换"): task.mark_processing() # ── mark_completed ─────────────────────────────────────────────────────────── class TestMarkCompleted: """测试 running → completed 转换。""" def test_running_to_completed_success(self) -> None: """running 状态的任务可以标记为 completed。""" task = _make_task() task.mark_processing() task.mark_completed() assert task.status == GenerationTaskStatus.COMPLETED assert task.is_completed assert task.is_terminal assert task.completed_at is not None def test_progress_set_to_100(self) -> None: """mark_completed 设置 progress 为 100.0。""" task = _make_task() task.mark_processing() task.progress = 50.0 # 模拟中间进度 task.mark_completed() assert task.progress == 100.0 def test_default_result_count_is_1(self) -> None: """默认 result_count 为 1。""" task = _make_task() task.mark_processing() task.mark_completed() assert task.result_count == 1 def test_custom_result_count(self) -> None: """可以指定 result_count。""" task = _make_task() task.mark_processing() task.mark_completed(result_count=5) assert task.result_count == 5 def test_error_message_cleared(self) -> None: """mark_completed 清除 error_message。""" task = _make_task() task.mark_processing() task.error_message = "temporary error" task.mark_completed() assert task.error_message == "" def test_completed_at_is_set(self) -> None: """mark_completed 设置 completed_at。""" task = _make_task() task.mark_processing() assert task.completed_at is None task.mark_completed() assert task.completed_at is not None def test_pending_to_completed_raises(self) -> None: """pending 状态不能直接到 completed。""" task = _make_task() with pytest.raises(ValueError, match="非法状态转换"): task.mark_completed() def test_completed_to_completed_raises(self) -> None: """completed 状态不能再次 mark_completed。""" task = _make_task() task.mark_processing() task.mark_completed() with pytest.raises(ValueError, match="非法状态转换"): task.mark_completed() def test_failed_to_completed_raises(self) -> None: """failed 状态不能直接到 completed。""" task = _make_task() task.mark_processing() task.mark_failed("error") with pytest.raises(ValueError, match="非法状态转换"): task.mark_completed() # ── mark_failed ────────────────────────────────────────────────────────────── class TestMarkFailed: """测试 pending/running → failed 转换。""" def test_pending_to_failed_success(self) -> None: """pending 状态可以直接标记为 failed。""" task = _make_task() task.mark_failed("资源不足") assert task.status == GenerationTaskStatus.FAILED assert task.is_failed assert task.is_terminal assert task.error_message == "资源不足" assert task.completed_at is not None def test_running_to_failed_success(self) -> None: """running 状态可以标记为 failed。""" task = _make_task() task.mark_processing() task.mark_failed("生成失败:FFmpeg 错误") assert task.status == GenerationTaskStatus.FAILED assert task.is_failed assert task.is_terminal assert task.error_message == "生成失败:FFmpeg 错误" assert task.completed_at is not None def test_completed_to_failed_raises(self) -> None: """completed 状态不能标记为 failed。""" task = _make_task() task.mark_processing() task.mark_completed() with pytest.raises(ValueError, match="非法状态转换"): task.mark_failed("late error") def test_failed_to_failed_raises(self) -> None: """failed 状态不能再次 mark_failed。""" task = _make_task() task.mark_failed("first error") with pytest.raises(ValueError, match="非法状态转换"): task.mark_failed("second error") def test_error_message_preserved(self) -> None: """错误信息被正确保存。""" task = _make_task() error_msg = "FFmpeg returned non-zero exit status 1" task.mark_failed(error_msg) assert task.error_message == error_msg # ── mark_cancelled ─────────────────────────────────────────────────────────── class TestMarkCancelled: """测试 pending/running → cancelled 转换。""" def test_pending_to_cancelled_success(self) -> None: """pending 状态可以取消。""" task = _make_task() task.mark_cancelled() assert task.status == GenerationTaskStatus.CANCELLED assert task.is_terminal assert task.completed_at is not None def test_running_to_cancelled_success(self) -> None: """running 状态可以取消。""" task = _make_task() task.mark_processing() task.mark_cancelled() assert task.status == GenerationTaskStatus.CANCELLED assert task.is_terminal def test_completed_to_cancelled_raises(self) -> None: """completed 状态不能取消。""" task = _make_task() task.mark_processing() task.mark_completed() with pytest.raises(ValueError, match="非法状态转换"): task.mark_cancelled() def test_failed_to_cancelled_raises(self) -> None: """failed 状态不能取消。""" task = _make_task() task.mark_failed("some error") with pytest.raises(ValueError, match="非法状态转换"): task.mark_cancelled() # ── mark_pending_from_failed (重试) ───────────────────────────────────────── class TestMarkPendingFromFailed: """测试 failed → pending(重试)转换。""" def test_failed_to_pending_success(self) -> None: """failed 状态可以重置为 pending(用于重试)。""" task = _make_task() task.mark_processing() task.mark_failed("临时错误") task.mark_pending_from_failed() assert task.status == GenerationTaskStatus.PENDING assert not task.is_terminal assert task.error_message == "" assert task.started_at is None assert task.completed_at is None assert task.progress == 0.0 assert task.result_count == 0 def test_pending_to_pending_raises(self) -> None: """pending 状态不能调用 mark_pending_from_failed。""" task = _make_task() with pytest.raises(ValueError, match="只有 failed 状态"): task.mark_pending_from_failed() def test_running_to_pending_raises(self) -> None: """running 状态不能调用 mark_pending_from_failed。""" task = _make_task() task.mark_processing() with pytest.raises(ValueError, match="只有 failed 状态"): task.mark_pending_from_failed() def test_completed_to_pending_raises(self) -> None: """completed 状态不能调用 mark_pending_from_failed。""" task = _make_task() task.mark_processing() task.mark_completed() with pytest.raises(ValueError, match="只有 failed 状态"): task.mark_pending_from_failed() # ── transition_to 通用方法 ─────────────────────────────────────────────────── class TestTransitionTo: """测试通用的 transition_to 方法。""" def test_string_status_conversion(self) -> None: """可以传入字符串形式的状态。""" task = _make_task() task.transition_to("running") assert task.status == GenerationTaskStatus.RUNNING def test_invalid_string_raises(self) -> None: """无效的状态字符串抛出 ValueError。""" task = _make_task() with pytest.raises(ValueError, match="非法状态转换"): task.transition_to("invalid_status") def test_enum_status(self) -> None: """可以传入枚举形式的状态。""" task = _make_task() task.transition_to(GenerationTaskStatus.RUNNING) assert task.status == GenerationTaskStatus.RUNNING def test_error_message_includes_allowed_statuses(self) -> None: """错误信息包含允许的状态列表。""" task = _make_task() task.mark_processing() task.mark_completed() with pytest.raises(ValueError) as exc_info: task.transition_to(GenerationTaskStatus.RUNNING) assert "completed" in str(exc_info.value) assert "running" in str(exc_info.value) # ── 完整流转路径 ───────────────────────────────────────────────────────────── class TestFullFlow: """测试完整的状态流转路径。""" def test_happy_path(self) -> None: """正常路径:pending → running → completed。""" task = _make_task() assert task.status == GenerationTaskStatus.PENDING assert not task.is_terminal task.mark_processing() assert task.status == GenerationTaskStatus.RUNNING assert task.started_at is not None assert not task.is_terminal task.mark_completed(result_count=3) assert task.status == GenerationTaskStatus.COMPLETED assert task.is_completed assert task.is_terminal assert task.completed_at is not None assert task.result_count == 3 assert task.progress == 100.0 def test_failure_path_from_running(self) -> None: """失败路径:pending → running → failed。""" task = _make_task() task.mark_processing() assert task.is_running task.mark_failed("网络超时") assert task.is_failed assert task.is_terminal assert task.error_message == "网络超时" assert task.completed_at is not None def test_failure_path_from_pending(self) -> None: """失败路径:pending → failed(启动前校验失败等)。""" task = _make_task() task.mark_failed("参数校验失败") assert task.is_failed assert task.is_terminal def test_retry_path(self) -> None: """重试路径:pending → running → failed → pending → running → completed。""" task = _make_task() # 第一次尝试失败 task.mark_processing() task.mark_failed("临时错误") assert task.is_failed # 重试 task.mark_pending_from_failed() assert task.status == GenerationTaskStatus.PENDING assert task.error_message == "" # 第二次成功 task.mark_processing() task.mark_completed() assert task.is_completed def test_cancel_from_pending(self) -> None: """取消路径:pending → cancelled。""" task = _make_task() task.mark_cancelled() assert task.status == GenerationTaskStatus.CANCELLED assert task.is_terminal def test_cancel_from_running(self) -> None: """取消路径:pending → running → cancelled。""" task = _make_task() task.mark_processing() task.mark_cancelled() assert task.status == GenerationTaskStatus.CANCELLED assert task.is_terminal # ── 错误信息与重试(任务中心升级) ────────────────────────────────────────── class TestErrorInfo: """测试 error_info 结构化错误信息。""" def test_mark_failed_default_error_info(self) -> None: """mark_failed 不传 error_info 时自动生成默认结构。""" task = _make_task() task.mark_processing() task.mark_failed("something went wrong") assert task.is_failed assert task.error_message == "something went wrong" assert task.error_info["error_type"] == "UnknownError" assert task.error_info["message"] == "something went wrong" assert "failed_at" in task.error_info def test_mark_failed_with_custom_error_info(self) -> None: """mark_failed 传自定义 error_info。""" task = _make_task() task.mark_processing() info = { "error_type": "FFmpegError", "message": "Invalid data found", "stack_trace": "Traceback...", "stage": "render", "failed_at": "2026-01-01T00:00:00+00:00", } task.mark_failed("Invalid data found", error_info=info) assert task.error_info == info def test_error_info_cleared_on_retry(self) -> None: """重试时 error_info 被清空。""" task = _make_task() task.mark_processing() task.mark_failed("oops") assert task.error_info # 失败时有值 task.mark_pending_from_failed() assert task.error_info == {} assert task.status == GenerationTaskStatus.PENDING class TestRetryCount: """测试 retry_count 重试次数。""" def test_default_retry_count_is_zero(self) -> None: """新任务 retry_count 默认 0。""" task = _make_task() assert task.retry_count == 0 def test_retry_increments_count(self) -> None: """每次失败后重试,retry_count +1。""" task = _make_task() task.mark_processing() task.mark_failed("fail 1") task.mark_pending_from_failed() assert task.retry_count == 1 task.mark_processing() task.mark_failed("fail 2") task.mark_pending_from_failed() assert task.retry_count == 2 def test_completed_does_not_affect_retry_count(self) -> None: """正常完成不改变 retry_count。""" task = _make_task() task.mark_processing() task.mark_completed() assert task.retry_count == 0 class TestAutoRetryConfig: """测试自动重试配置。""" def test_default_auto_retry_disabled(self) -> None: """默认关闭自动重试。""" task = _make_task() assert task.auto_retry_enabled is False assert task.auto_retry_max == 0 def test_create_with_auto_retry(self) -> None: """create 工厂方法支持 auto_retry 参数。""" task = GenerationTask.create( project_id="proj-1", asset_library_id="lib-1", auto_retry_enabled=True, auto_retry_max=3, ) assert task.auto_retry_enabled is True assert task.auto_retry_max == 3 def test_auto_retry_max_default_zero(self) -> None: """auto_retry_max 默认 0 表示不自动重试。""" task = GenerationTask.create( project_id="proj-1", asset_library_id="lib-1", auto_retry_enabled=True, ) assert task.auto_retry_enabled is True assert task.auto_retry_max == 0