Files
photoanalyzer/tests/unit/test_rename_journal_states.py

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