191 lines
8.3 KiB
Python
191 lines
8.3 KiB
Python
|
|
"""Tests for ``app.afk.run_state_machine.next_action`` — the pure decision
|
||
|
|
function that turns one assembled ``RunState`` into the next ``Action``.
|
||
|
|
|
||
|
|
The function encodes ADR-0002's run lifecycle:
|
||
|
|
|
||
|
|
* healthy (pushed AND CI green) -> CLOSE_SUCCESS
|
||
|
|
* cannot reach green before push (errored /
|
||
|
|
stalled with nothing pushed) -> ESCALATE_PREPUSH
|
||
|
|
* pushed but CI red, budget remaining -> FIX_FORWARD
|
||
|
|
* pushed but CI red, budget exhausted -> FREEZE_ESCALATE
|
||
|
|
* anything still in flight -> WAIT
|
||
|
|
|
||
|
|
It is PURE: no I/O, no clock, no globals — it reads only its two arguments, so
|
||
|
|
every case is a plain table assertion. ``make_config`` / ``make_run_state`` come
|
||
|
|
from ``conftest.py`` (config defaults to ENABLED, run state to a fresh dispatch).
|
||
|
|
"""
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.afk.run_state_machine import next_action
|
||
|
|
from app.afk.types import Action, CIStatus, ThreadStatus
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Healthy terminal: pushed + CI green -> close, regardless of thread status.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"thread_status",
|
||
|
|
[ThreadStatus.RUNNING, ThreadStatus.IDLE, ThreadStatus.ERROR, None],
|
||
|
|
)
|
||
|
|
def test_pushed_and_green_closes_success(make_config, make_run_state, thread_status):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=thread_status, ci_status=CIStatus.GREEN, pushed=True
|
||
|
|
)
|
||
|
|
assert next_action(state, make_config()) is Action.CLOSE_SUCCESS
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Pre-push escalation: nothing pushed and the turn is no longer going to push
|
||
|
|
# (errored, or finished/stalled clean) -> hand back to a human.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize("thread_status", [ThreadStatus.ERROR, ThreadStatus.IDLE])
|
||
|
|
@pytest.mark.parametrize("ci_status", [None, CIStatus.PENDING])
|
||
|
|
def test_not_pushed_terminal_thread_escalates_prepush(
|
||
|
|
make_config, make_run_state, thread_status, ci_status
|
||
|
|
):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=thread_status, ci_status=ci_status, pushed=False
|
||
|
|
)
|
||
|
|
assert next_action(state, make_config()) is Action.ESCALATE_PREPUSH
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Still working toward a first push -> WAIT (not yet an escalation).
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize("thread_status", [ThreadStatus.RUNNING, None])
|
||
|
|
@pytest.mark.parametrize("ci_status", [None, CIStatus.PENDING])
|
||
|
|
def test_not_pushed_in_flight_waits(
|
||
|
|
make_config, make_run_state, thread_status, ci_status
|
||
|
|
):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=thread_status, ci_status=ci_status, pushed=False
|
||
|
|
)
|
||
|
|
assert next_action(state, make_config()) is Action.WAIT
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Pushed, CI not yet decided -> WAIT for the verdict, whatever the thread does.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"thread_status",
|
||
|
|
[ThreadStatus.RUNNING, ThreadStatus.IDLE, ThreadStatus.ERROR, None],
|
||
|
|
)
|
||
|
|
@pytest.mark.parametrize("ci_status", [None, CIStatus.PENDING])
|
||
|
|
def test_pushed_ci_pending_waits(
|
||
|
|
make_config, make_run_state, thread_status, ci_status
|
||
|
|
):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=thread_status, ci_status=ci_status, pushed=True
|
||
|
|
)
|
||
|
|
assert next_action(state, make_config()) is Action.WAIT
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Pushed + CI red: fix-forward while BOTH budgets remain, else freeze.
|
||
|
|
# Boundaries are strict-less-than on attempts AND elapsed; at/over either freezes.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("attempts", "elapsed", "expected"),
|
||
|
|
[
|
||
|
|
# fresh red, plenty of budget -> fix forward
|
||
|
|
(0, 0.0, Action.FIX_FORWARD),
|
||
|
|
(1, 10.0, Action.FIX_FORWARD),
|
||
|
|
# one attempt below the cap, well inside the clock -> still fix forward
|
||
|
|
(4, 3599.0, Action.FIX_FORWARD),
|
||
|
|
# attempts hit the cap (5) -> freeze
|
||
|
|
(5, 0.0, Action.FREEZE_ESCALATE),
|
||
|
|
(6, 0.0, Action.FREEZE_ESCALATE),
|
||
|
|
# clock hits the cap (3600s) -> freeze even with attempts to spare
|
||
|
|
(0, 3600.0, Action.FREEZE_ESCALATE),
|
||
|
|
(0, 7200.0, Action.FREEZE_ESCALATE),
|
||
|
|
# both exhausted -> freeze
|
||
|
|
(5, 3600.0, Action.FREEZE_ESCALATE),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_pushed_red_fix_forward_until_budget_exhausted(
|
||
|
|
make_config, make_run_state, attempts, elapsed, expected
|
||
|
|
):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=ThreadStatus.IDLE,
|
||
|
|
ci_status=CIStatus.RED,
|
||
|
|
pushed=True,
|
||
|
|
fix_forward_attempts=attempts,
|
||
|
|
elapsed_seconds=elapsed,
|
||
|
|
)
|
||
|
|
assert next_action(state, make_config()) is expected
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Fix-forward budget is honoured from config, not hardcoded.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_fix_forward_attempts_cap_comes_from_config(make_config, make_run_state):
|
||
|
|
config = make_config(fix_forward_max_attempts=2)
|
||
|
|
red = dict(thread_status=ThreadStatus.IDLE, ci_status=CIStatus.RED, pushed=True)
|
||
|
|
assert next_action(make_run_state(fix_forward_attempts=1, **red), config) is Action.FIX_FORWARD
|
||
|
|
assert next_action(make_run_state(fix_forward_attempts=2, **red), config) is Action.FREEZE_ESCALATE
|
||
|
|
|
||
|
|
|
||
|
|
def test_fix_forward_seconds_cap_comes_from_config(make_config, make_run_state):
|
||
|
|
config = make_config(fix_forward_max_seconds=120)
|
||
|
|
red = dict(thread_status=ThreadStatus.IDLE, ci_status=CIStatus.RED, pushed=True)
|
||
|
|
assert next_action(make_run_state(elapsed_seconds=119.0, **red), config) is Action.FIX_FORWARD
|
||
|
|
assert next_action(make_run_state(elapsed_seconds=120.0, **red), config) is Action.FREEZE_ESCALATE
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# A red CI on a pushed commit while the thread is still RUNNING a fix is, per
|
||
|
|
# spec, keyed only on (pushed AND red) + budget — thread status doesn't gate it.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"thread_status",
|
||
|
|
[ThreadStatus.RUNNING, ThreadStatus.IDLE, ThreadStatus.ERROR, None],
|
||
|
|
)
|
||
|
|
def test_pushed_red_with_budget_fixes_forward_for_any_thread_status(
|
||
|
|
make_config, make_run_state, thread_status
|
||
|
|
):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=thread_status,
|
||
|
|
ci_status=CIStatus.RED,
|
||
|
|
pushed=True,
|
||
|
|
fix_forward_attempts=0,
|
||
|
|
elapsed_seconds=0.0,
|
||
|
|
)
|
||
|
|
assert next_action(state, make_config()) is Action.FIX_FORWARD
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Full cross-product sanity sweep: next_action is TOTAL — it returns a real
|
||
|
|
# Action for every reachable combination, and matches the reference table.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def _expected(thread_status, ci_status, pushed):
|
||
|
|
"""Reference implementation of the decision table, written independently of
|
||
|
|
the module under test, to cross-check every combination."""
|
||
|
|
if pushed and ci_status is CIStatus.GREEN:
|
||
|
|
return Action.CLOSE_SUCCESS
|
||
|
|
if pushed and ci_status is CIStatus.RED:
|
||
|
|
return Action.FIX_FORWARD # budget always available in this sweep
|
||
|
|
if not pushed and thread_status in (ThreadStatus.ERROR, ThreadStatus.IDLE):
|
||
|
|
return Action.ESCALATE_PREPUSH
|
||
|
|
return Action.WAIT
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"thread_status",
|
||
|
|
[ThreadStatus.RUNNING, ThreadStatus.IDLE, ThreadStatus.ERROR, None],
|
||
|
|
)
|
||
|
|
@pytest.mark.parametrize("ci_status", [None, CIStatus.PENDING, CIStatus.GREEN, CIStatus.RED])
|
||
|
|
@pytest.mark.parametrize("pushed", [True, False])
|
||
|
|
def test_decision_table_is_total(
|
||
|
|
make_config, make_run_state, thread_status, ci_status, pushed
|
||
|
|
):
|
||
|
|
state = make_run_state(
|
||
|
|
thread_status=thread_status,
|
||
|
|
ci_status=ci_status,
|
||
|
|
pushed=pushed,
|
||
|
|
fix_forward_attempts=0,
|
||
|
|
elapsed_seconds=0.0,
|
||
|
|
)
|
||
|
|
result = next_action(state, make_config())
|
||
|
|
assert isinstance(result, Action)
|
||
|
|
assert result is _expected(thread_status, ci_status, pushed)
|