Files
chongming-utils-task/tests/unit/test_models.py
T
chakcy f4de43c743 初始提交:chongming-task 任务控制库
单任务受控执行(15 态状态机 + 审计轨迹)、DAG 管道、Controller 门面。
执行器三个:线程(默认,含 async def 任务)、内联(同步 / 测试)、进程
(spawn 子进程:硬性停止、绕开 GIL、崩溃隔离)。
调度:并发额度 + 每桶限流 + 具名资源信号量(按标签)+ 优先级排队。
持久层:TaskStore 契约 + 内存 / SQLite / Postgres 实现,契约测试随包发布。
跨进程与多节点:写穿 / 读穿、指令通道与租约、owned_buckets 划界、reconcile。
CLI、12 个可直接运行的示例、docs/ 下 5 份文档。

门禁:ruff / ruff format / mypy(strict) 干净;
      pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
      带一台可连的 Postgres 时 607 passed。
2026-10-08 23:47:50 +08:00

144 lines
4.8 KiB
Python

"""快照与正交维度模型的不变量。
重点覆盖那些"容易被合并成单一状态枚举"的维度:进度、失败原因、请求动作。
"""
from __future__ import annotations
import dataclasses
from datetime import UTC, datetime
import pytest
from chongming_task.models import (
AttemptRecord,
Failure,
FailureKind,
Progress,
RequestedAction,
TaskSnapshot,
TimeoutScope,
)
from chongming_task.states import TaskStatus
def test_progress_fraction_is_none_when_total_unknown() -> None:
"""total 未知必须返回 None 而非 0.0——后者会被渲染成"尚未开始"。"""
assert Progress(done=5).fraction is None
assert Progress(done=0, total=0).fraction is None
assert Progress(done=25, total=100).fraction == 0.25
def test_progress_rejects_done_beyond_total() -> None:
with pytest.raises(ValueError, match="不能超过"):
Progress(done=11, total=10)
def test_progress_rejects_negative_values() -> None:
with pytest.raises(ValueError, match="不能为负"):
Progress(done=-1)
with pytest.raises(ValueError, match="不能为负"):
Progress(total=-1)
def test_timeout_scope_maps_to_failure_kind() -> None:
"""超时是失败原因而非生命周期状态,两者的对应关系必须稳定。"""
assert FailureKind.for_timeout(TimeoutScope.START) is FailureKind.TIMEOUT_START
assert FailureKind.for_timeout(TimeoutScope.ATTEMPT) is FailureKind.TIMEOUT_ATTEMPT
assert FailureKind.for_timeout(TimeoutScope.IDLE) is FailureKind.TIMEOUT_IDLE
def test_attempt_record_numbering_starts_at_one() -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
with pytest.raises(ValueError, match="从 1 开始"):
AttemptRecord(task_id="t1", attempt=0, started_at=now)
def test_snapshot_requires_identity() -> None:
with pytest.raises(ValueError, match="task_id"):
TaskSnapshot(task_id="", name="x")
with pytest.raises(ValueError, match="name"):
TaskSnapshot(task_id="t1", name="")
def test_snapshot_rejects_attempt_beyond_max_attempts() -> None:
with pytest.raises(ValueError, match="max_attempts"):
TaskSnapshot(task_id="t1", name="x", attempt=3, max_attempts=2)
def test_snapshot_is_frozen() -> None:
"""快照不可变,才能安全跨线程/协程传递并直接作为持久化记录。"""
snap = TaskSnapshot(task_id="t1", name="x")
with pytest.raises(dataclasses.FrozenInstanceError):
snap.status = TaskStatus.RUNNING # type: ignore[misc]
def test_validate_flags_terminal_missing_ended_at() -> None:
snap = TaskSnapshot(task_id="t1", name="x", status=TaskStatus.SUCCEEDED)
assert any("ended_at" in problem for problem in snap.validate())
def test_validate_flags_leftover_requested_action_on_terminal() -> None:
"""终态残留请求会让 UI 永远显示"停止中"。"""
now = datetime(2026, 1, 1, tzinfo=UTC)
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.STOPPED,
ended_at=now,
requested_action=RequestedAction.STOP,
)
assert any("requested_action" in problem for problem in snap.validate())
def test_retry_wait_may_carry_failure() -> None:
"""RETRY_WAIT 必须能携带上次失败原因,否则界面无法回答"它为什么在等"。"""
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.RETRY_WAIT,
failure=Failure(kind=FailureKind.EXCEPTION, message="boom"),
)
assert snap.validate() == ()
def test_retry_wait_without_failure_is_inconsistent() -> None:
snap = TaskSnapshot(task_id="t1", name="x", status=TaskStatus.RETRY_WAIT)
assert any("RETRY_WAIT" in problem for problem in snap.validate())
def test_success_must_not_carry_failure() -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.SUCCEEDED,
ended_at=now,
failure=Failure(kind=FailureKind.EXCEPTION, message="boom"),
)
assert any("SUCCEEDED" in problem for problem in snap.validate())
def test_consistent_terminal_snapshot_validates_clean() -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.FAILED,
ended_at=now,
attempt=1,
max_attempts=1,
failure=Failure(kind=FailureKind.EXCEPTION, message="boom"),
)
assert snap.validate() == ()
assert snap.is_terminal
assert snap.is_revivable
def test_running_snapshot_requires_started_at_only_on_validate() -> None:
"""时间线完整性刻意不放在 __post_init__,以免逐步构造快照时处处受限。"""
snap = TaskSnapshot(task_id="t1", name="x", status=TaskStatus.RUNNING)
assert any("started_at" in problem for problem in snap.validate())
# 但构造本身不报错
assert snap.status is TaskStatus.RUNNING