Files

119 lines
4.8 KiB
Python
Raw Permalink Normal View History

"""用 hypothesis 随机生成事件序列,验证状态机的核心不变量。
比手写用例更强的地方:它能生成人手想不到的组合,例如
"暂停中收到停止"、"重试等待中被重试"、"重试预算耗尽后又被派发"这类长尾序列。
被验证的不变量
--------------
1. 被拒绝的动作**完全不产生副作用**(快照对象不变、历史不增长)。
2. 每次被接受的动作使 ``status_version`` 恰好 +1,并使历史恰好 +1 条。
3. 除显式审计的复活路径外,实际发生的转移必在转移表内。
4. 终态不可逃逸。
5. 每次被接受的动作之后,快照都通过 :meth:`TaskSnapshot.validate` 的跨字段一致性检查。
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from chongming_task.core.state_machine import StateMachine, Trigger
from chongming_task.errors import AttemptsExhausted, InvalidTransition
from chongming_task.models import Failure, FailureKind
from chongming_task.states import TERMINAL, TRANSITIONS
from chongming_task.testing import FakeClock
if TYPE_CHECKING:
from collections.abc import Callable
_FAILURE = Failure(kind=FailureKind.EXCEPTION, message="boom")
#: 刻意绕过转移表的触发源,只有这三条审计路径合法。
_NON_TABLE_TRIGGERS = frozenset(
{
Trigger.RETRY_REQUESTED,
Trigger.RESET_REQUESTED,
Trigger.ADMIN_OVERRIDE,
}
)
#: 随机动作集合。
#:
#: 刻意不含 ``force_transition``:它按定义可跳到任意状态,与"随机事件序列"这一
#: 模型正交,其行为由 tests/unit/test_state_machine.py 的定向用例覆盖。
ACTIONS: tuple[tuple[str, Callable[[StateMachine], object]], ...] = (
("mark_blocked", lambda m: m.mark_blocked()),
("mark_queued", lambda m: m.mark_queued()),
("mark_starting", lambda m: m.mark_starting(executor="inline")),
("mark_running", lambda m: m.mark_running()),
("mark_succeeded", lambda m: m.mark_succeeded()),
("mark_failed", lambda m: m.mark_failed(_FAILURE)),
("mark_retry_wait", lambda m: m.mark_retry_wait(_FAILURE)),
("mark_retry_exhausted", lambda m: m.mark_retry_exhausted(_FAILURE)),
("mark_skipped", lambda m: m.mark_skipped()),
("mark_cancelled", lambda m: m.mark_cancelled()),
("mark_lost", lambda m: m.mark_lost()),
("request_stop", lambda m: m.request_stop()),
("mark_stopped", lambda m: m.mark_stopped()),
("request_pause", lambda m: m.request_pause()),
("mark_paused", lambda m: m.mark_paused()),
("abort_pause", lambda m: m.abort_pause()),
("resume", lambda m: m.resume()),
("retry", lambda m: m.retry()),
("force_retry", lambda m: m.retry(force=True)),
("reset", lambda m: m.reset()),
)
@settings(
max_examples=500,
deadline=None,
suppress_health_check=[HealthCheck.too_slow],
)
@given(
max_attempts=st.integers(min_value=1, max_value=4),
picks=st.lists(st.integers(min_value=0, max_value=len(ACTIONS) - 1), max_size=30),
)
def test_invariants_hold_for_any_action_sequence(max_attempts: int, picks: list[int]) -> None:
clock = FakeClock()
machine = StateMachine.create("t", "x", max_attempts=max_attempts, clock=clock)
for index in picks:
name, action = ACTIONS[index]
clock.advance(1)
before = machine.snapshot
history_len = len(machine.history)
try:
action(machine)
except (InvalidTransition, AttemptsExhausted):
# 不变量 1:被拒绝的动作必须完全无副作用。
assert machine.snapshot is before, f"{name} 被拒绝却改动了快照"
assert len(machine.history) == history_len, f"{name} 被拒绝却写了历史"
continue
after = machine.snapshot
record = machine.history[-1]
# 不变量 2:版本号与历史同步严格 +1。
assert len(machine.history) == history_len + 1, name
assert after.status_version == before.status_version + 1, name
# 不变量 3:只有审计路径可以绕过转移表。
if record.trigger not in _NON_TABLE_TRIGGERS:
assert after.status in TRANSITIONS[before.status], (
f"{name} 绕过了转移表:{before.status} -> {after.status}"
)
# 不变量 4:终态不可逃逸——**复活除外**,后者必须走审计路径。
# 终态出边为空集,因此这一条其实是上一条的推论;显式写出是为了失败时
# 能一眼分清"逃逸"与"绕表"。
if record.trigger not in _NON_TABLE_TRIGGERS:
assert before.status not in TERMINAL, f"{name} 从终态 {before.status} 逃逸"
# 不变量 5:快照跨字段一致。
problems = after.validate()
assert problems == (), f"{name} 产生不一致快照:{problems}"