Files
chongming-utils-task/tests/unit/test_models.py
T

144 lines
4.8 KiB
Python
Raw Normal View History

"""快照与正交维度模型的不变量。
重点覆盖那些"容易被合并成单一状态枚举"的维度:进度、失败原因、请求动作。
"""
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