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