139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
"""Rename journal state machine and startup classification (US04-02).
|
|
|
|
Pure-logic coverage: every valid and invalid transition in the matrix, the terminal
|
|
states, the states that make the library unsafe to touch, and the full evidence table
|
|
that turns (journal state, source exists, destination exists) into a recovery
|
|
classification. No database and no filesystem here — the repository behaviour is
|
|
covered by tests/integration/test_rename_journal.py.
|
|
"""
|
|
|
|
import itertools
|
|
|
|
from photo_pipeline.services.rename_journal import (
|
|
ALLOWED_TRANSITIONS,
|
|
MANUAL,
|
|
RESUMABLE,
|
|
ROLLBACK_SAFE,
|
|
TERMINAL_STATES,
|
|
UNSAFE_STATES,
|
|
JournalState,
|
|
_classify,
|
|
can_transition,
|
|
)
|
|
|
|
ALL_STATES = set(ALLOWED_TRANSITIONS)
|
|
|
|
|
|
def test_every_state_is_declared_in_the_matrix():
|
|
declared = {
|
|
value
|
|
for name, value in vars(JournalState).items()
|
|
if not name.startswith("_") and isinstance(value, str)
|
|
}
|
|
assert declared == ALL_STATES
|
|
# Every target is itself a declared state — no dangling edges.
|
|
for targets in ALLOWED_TRANSITIONS.values():
|
|
assert targets <= ALL_STATES
|
|
|
|
|
|
def test_the_happy_path_is_walkable_end_to_end():
|
|
path = [
|
|
JournalState.PLANNED,
|
|
JournalState.MOVING,
|
|
JournalState.MOVED,
|
|
JournalState.DATABASE_UPDATED,
|
|
JournalState.VERIFIED,
|
|
JournalState.COMPLETE,
|
|
]
|
|
for current, target in itertools.pairwise(path):
|
|
assert can_transition(current, target), f"{current} -> {target} must be allowed"
|
|
|
|
|
|
def test_terminal_states_allow_no_further_transition():
|
|
for state in TERMINAL_STATES:
|
|
assert ALLOWED_TRANSITIONS[state] == set()
|
|
for target in ALL_STATES:
|
|
assert not can_transition(state, target)
|
|
|
|
|
|
def test_invalid_transitions_are_rejected():
|
|
# A few that must never be possible, spelled out rather than derived.
|
|
assert not can_transition(JournalState.PLANNED, JournalState.COMPLETE)
|
|
assert not can_transition(JournalState.PLANNED, JournalState.MOVED)
|
|
assert not can_transition(JournalState.MOVING, JournalState.COMPLETE)
|
|
assert not can_transition(JournalState.MOVED, JournalState.PLANNED)
|
|
assert not can_transition(JournalState.COMPLETE, JournalState.ROLLED_BACK)
|
|
assert not can_transition(JournalState.ROLLED_BACK, JournalState.PLANNED)
|
|
|
|
|
|
def test_the_whole_matrix_is_exhaustively_consistent():
|
|
for current, target in itertools.product(ALL_STATES, repeat=2):
|
|
expected = target in ALLOWED_TRANSITIONS[current]
|
|
assert can_transition(current, target) is expected
|
|
|
|
|
|
def test_unsafe_states_are_the_ones_where_disk_may_have_changed():
|
|
assert UNSAFE_STATES == {
|
|
JournalState.MOVING,
|
|
JournalState.MOVED,
|
|
JournalState.DATABASE_UPDATED,
|
|
# The move happened and a human still has to decide about it (US07-04).
|
|
JournalState.ROLLBACK_REQUIRED,
|
|
}
|
|
# planned has not touched anything; complete/rolled_back are settled.
|
|
assert JournalState.PLANNED not in UNSAFE_STATES
|
|
assert not (TERMINAL_STATES & UNSAFE_STATES)
|
|
|
|
|
|
# ── evidence table ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_moving_with_source_intact_is_resumable():
|
|
assert _classify(JournalState.MOVING, True, False)[0] == RESUMABLE
|
|
|
|
|
|
def test_moving_with_only_destination_is_rollback_safe():
|
|
assert _classify(JournalState.MOVING, False, True)[0] == ROLLBACK_SAFE
|
|
|
|
|
|
def test_moving_with_both_or_neither_path_requires_manual_recovery():
|
|
assert _classify(JournalState.MOVING, True, True)[0] == MANUAL
|
|
assert _classify(JournalState.MOVING, False, False)[0] == MANUAL
|
|
|
|
|
|
def test_recorded_move_contradicted_by_disk_requires_manual_recovery():
|
|
for state in (JournalState.MOVED, JournalState.DATABASE_UPDATED, JournalState.VERIFIED):
|
|
# Journal says the content moved, but it is still at the source.
|
|
assert _classify(state, True, False)[0] == MANUAL
|
|
# Both present: an unexpected occupant; never guess.
|
|
assert _classify(state, True, True)[0] == MANUAL
|
|
# Neither present: the content is gone.
|
|
assert _classify(state, False, False)[0] == MANUAL
|
|
# The expected shape is recoverable.
|
|
assert _classify(state, False, True)[0] == ROLLBACK_SAFE
|
|
|
|
|
|
def test_rollback_required_classification():
|
|
assert _classify(JournalState.ROLLBACK_REQUIRED, True, False)[0] == RESUMABLE
|
|
assert _classify(JournalState.ROLLBACK_REQUIRED, False, True)[0] == ROLLBACK_SAFE
|
|
assert _classify(JournalState.ROLLBACK_REQUIRED, True, True)[0] == MANUAL
|
|
|
|
|
|
def test_failed_classification():
|
|
assert _classify(JournalState.FAILED, True, False)[0] == RESUMABLE
|
|
assert _classify(JournalState.FAILED, False, True)[0] == MANUAL
|
|
assert _classify(JournalState.FAILED, True, True)[0] == MANUAL
|
|
|
|
|
|
def test_every_classification_carries_a_reason():
|
|
for state in ALL_STATES:
|
|
for source, destination in itertools.product([True, False], repeat=2):
|
|
classification, reason = _classify(state, source, destination)
|
|
assert classification in {RESUMABLE, ROLLBACK_SAFE, MANUAL}
|
|
assert reason.strip(), f"{state} {source} {destination} has no reason"
|
|
|
|
|
|
def test_unknown_state_falls_back_to_manual():
|
|
# An unexpected value must never be treated as safe.
|
|
assert _classify("something_new", True, False)[0] == MANUAL
|