|
|
@@ -0,0 +1,381 @@
|
|
|
+"""
|
|
|
+Unit tests for StateMachine implementation in src/core.
|
|
|
+
|
|
|
+Tests cover state transitions, callbacks, context management,
|
|
|
+and error handling.
|
|
|
+"""
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from src.core.states import PipelineState
|
|
|
+from src.core.state_machine import (
|
|
|
+ StateMachine,
|
|
|
+ StateMachineError,
|
|
|
+ InvalidTransitionError,
|
|
|
+ TransitionEvent,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+class TestStateMachineInitialization:
|
|
|
+ """Test StateMachine initialization and basic properties."""
|
|
|
+
|
|
|
+ def test_initial_state_is_idle(self):
|
|
|
+ """Test that state machine starts in IDLE state."""
|
|
|
+ sm = StateMachine()
|
|
|
+ assert sm.state == PipelineState.IDLE
|
|
|
+
|
|
|
+ def test_context_is_empty_on_init(self):
|
|
|
+ """Test that context is empty on initialization."""
|
|
|
+ sm = StateMachine()
|
|
|
+ assert sm.context == {}
|
|
|
+ assert len(sm.context) == 0
|
|
|
+
|
|
|
+ def test_history_is_empty_on_init(self):
|
|
|
+ """Test that history is empty on initialization."""
|
|
|
+ sm = StateMachine()
|
|
|
+ assert sm.history == []
|
|
|
+ assert len(sm.history) == 0
|
|
|
+
|
|
|
+ def test_get_state_info(self):
|
|
|
+ """Test getting comprehensive state information."""
|
|
|
+ sm = StateMachine()
|
|
|
+ info = sm.get_state_info()
|
|
|
+
|
|
|
+ assert info["current_state"] == "idle"
|
|
|
+ assert info["is_terminal"] is False
|
|
|
+ assert info["is_active"] is False
|
|
|
+ assert info["transition_count"] == 0
|
|
|
+ assert "fingerprinting" in info["allowed_transitions"]
|
|
|
+
|
|
|
+
|
|
|
+class TestValidTransitions:
|
|
|
+ """Test valid state transitions."""
|
|
|
+
|
|
|
+ def test_idle_to_fingerprinting(self):
|
|
|
+ """Test IDLE -> FINGERPRINTING transition."""
|
|
|
+ sm = StateMachine()
|
|
|
+ result = sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+
|
|
|
+ assert result is True
|
|
|
+ assert sm.state == PipelineState.FINGERPRINTING
|
|
|
+
|
|
|
+ def test_normal_pipeline_flow(self):
|
|
|
+ """Test complete normal flow through the pipeline."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ flow = [
|
|
|
+ PipelineState.FINGERPRINTING,
|
|
|
+ PipelineState.CLEANING,
|
|
|
+ PipelineState.TERM_EXTRACTION,
|
|
|
+ PipelineState.TRANSLATING,
|
|
|
+ PipelineState.UPLOADING,
|
|
|
+ PipelineState.COMPLETED,
|
|
|
+ ]
|
|
|
+
|
|
|
+ for target_state in flow:
|
|
|
+ result = sm.transition_to(target_state)
|
|
|
+ assert result is True, f"Failed to transition to {target_state}"
|
|
|
+ assert sm.state == target_state
|
|
|
+
|
|
|
+ def test_transition_to_failed(self):
|
|
|
+ """Test transition from active state to FAILED."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+
|
|
|
+ result = sm.transition_to(PipelineState.FAILED)
|
|
|
+ assert result is True
|
|
|
+ assert sm.state == PipelineState.FAILED
|
|
|
+
|
|
|
+ def test_failed_to_idle(self):
|
|
|
+ """Test reset from FAILED to IDLE."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+ sm.transition_to(PipelineState.FAILED)
|
|
|
+
|
|
|
+ result = sm.transition_to(PipelineState.IDLE)
|
|
|
+ assert result is True
|
|
|
+ assert sm.state == PipelineState.IDLE
|
|
|
+
|
|
|
+ def test_pause_and_resume(self):
|
|
|
+ """Test pausing from active state and resuming."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ # Pause
|
|
|
+ assert sm.transition_to(PipelineState.PAUSED) is True
|
|
|
+ assert sm.state == PipelineState.PAUSED
|
|
|
+
|
|
|
+ # Resume
|
|
|
+ assert sm.transition_to(PipelineState.TRANSLATING) is True
|
|
|
+ assert sm.state == PipelineState.TRANSLATING
|
|
|
+
|
|
|
+
|
|
|
+class TestInvalidTransitions:
|
|
|
+ """Test invalid state transitions."""
|
|
|
+
|
|
|
+ def test_idle_direct_to_translating(self):
|
|
|
+ """Test that IDLE cannot transition directly to TRANSLATING."""
|
|
|
+ sm = StateMachine()
|
|
|
+ result = sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ assert result is False
|
|
|
+ assert sm.state == PipelineState.IDLE
|
|
|
+
|
|
|
+ def test_completed_to_active_state(self):
|
|
|
+ """Test that COMPLETED cannot transition directly to active states."""
|
|
|
+ sm = StateMachine()
|
|
|
+ # Complete the pipeline
|
|
|
+ for state in [
|
|
|
+ PipelineState.FINGERPRINTING,
|
|
|
+ PipelineState.CLEANING,
|
|
|
+ PipelineState.TERM_EXTRACTION,
|
|
|
+ PipelineState.TRANSLATING,
|
|
|
+ PipelineState.UPLOADING,
|
|
|
+ PipelineState.COMPLETED,
|
|
|
+ ]:
|
|
|
+ sm.transition_to(state)
|
|
|
+
|
|
|
+ # Try to go directly to TRANSLATING
|
|
|
+ result = sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+ assert result is False
|
|
|
+ assert sm.state == PipelineState.COMPLETED
|
|
|
+
|
|
|
+ def test_backward_transition(self):
|
|
|
+ """Test that backward transitions are not allowed."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ # Try to go back to CLEANING
|
|
|
+ result = sm.transition_to(PipelineState.CLEANING)
|
|
|
+ assert result is False
|
|
|
+ assert sm.state == PipelineState.TRANSLATING
|
|
|
+
|
|
|
+ def test_can_transition_to(self):
|
|
|
+ """Test can_transition_to method."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ assert sm.can_transition_to(PipelineState.FINGERPRINTING) is True
|
|
|
+ assert sm.can_transition_to(PipelineState.TRANSLATING) is False
|
|
|
+
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ assert sm.can_transition_to(PipelineState.UPLOADING) is True
|
|
|
+ assert sm.can_transition_to(PipelineState.CLEANING) is False
|
|
|
+
|
|
|
+
|
|
|
+class TestTransitionOrRaise:
|
|
|
+ """Test transition_or_raise method."""
|
|
|
+
|
|
|
+ def test_valid_transition_with_or_raise(self):
|
|
|
+ """Test valid transition with transition_or_raise."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_or_raise(PipelineState.FINGERPRINTING)
|
|
|
+ assert sm.state == PipelineState.FINGERPRINTING
|
|
|
+
|
|
|
+ def test_invalid_transition_raises_error(self):
|
|
|
+ """Test that invalid transition raises InvalidTransitionError."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ with pytest.raises(InvalidTransitionError) as exc_info:
|
|
|
+ sm.transition_or_raise(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ assert exc_info.value.from_state == PipelineState.IDLE
|
|
|
+ assert exc_info.value.to_state == PipelineState.TRANSLATING
|
|
|
+
|
|
|
+
|
|
|
+class TestContextManagement:
|
|
|
+ """Test context storage and retrieval."""
|
|
|
+
|
|
|
+ def test_context_update_on_transition(self):
|
|
|
+ """Test that context is updated during transition."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING, file_path="/test.txt")
|
|
|
+
|
|
|
+ assert sm.get_context_value("file_path") == "/test.txt"
|
|
|
+
|
|
|
+ def test_set_and_get_context_value(self):
|
|
|
+ """Test setting and getting context values."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ sm.set_context_value("key1", "value1")
|
|
|
+ sm.set_context_value("key2", 123)
|
|
|
+
|
|
|
+ assert sm.get_context_value("key1") == "value1"
|
|
|
+ assert sm.get_context_value("key2") == 123
|
|
|
+ assert sm.get_context_value("key3", "default") == "default"
|
|
|
+
|
|
|
+ def test_context_returns_copy(self):
|
|
|
+ """Test that context property returns a copy."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.set_context_value("key", "value")
|
|
|
+
|
|
|
+ context1 = sm.context
|
|
|
+ context2 = sm.context
|
|
|
+
|
|
|
+ # Modifying returned dict should not affect internal state
|
|
|
+ context1["key"] = "modified"
|
|
|
+
|
|
|
+ assert sm.get_context_value("key") == "value"
|
|
|
+ assert context2["key"] == "value"
|
|
|
+
|
|
|
+ def test_clear_context(self):
|
|
|
+ """Test clearing context."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.set_context_value("key1", "value1")
|
|
|
+ sm.set_context_value("key2", "value2")
|
|
|
+
|
|
|
+ sm.clear_context()
|
|
|
+
|
|
|
+ assert sm.context == {}
|
|
|
+ assert sm.get_context_value("key1") is None
|
|
|
+ assert sm.get_context_value("key2") is None
|
|
|
+
|
|
|
+
|
|
|
+class TestCallbacks:
|
|
|
+ """Test callback system."""
|
|
|
+
|
|
|
+ def test_on_transition_callback(self):
|
|
|
+ """Test on_transition callback."""
|
|
|
+ sm = StateMachine()
|
|
|
+ events = []
|
|
|
+
|
|
|
+ def callback(event):
|
|
|
+ events.append(event)
|
|
|
+
|
|
|
+ sm.register_callback("on_transition", callback)
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+
|
|
|
+ assert len(events) == 1
|
|
|
+ assert events[0].from_state == PipelineState.IDLE
|
|
|
+ assert events[0].to_state == PipelineState.FINGERPRINTING
|
|
|
+
|
|
|
+ def test_on_enter_state_callback(self):
|
|
|
+ """Test on_enter_<state> callbacks."""
|
|
|
+ sm = StateMachine()
|
|
|
+ states = []
|
|
|
+
|
|
|
+ sm.register_callback("on_enter_translating", lambda e: states.append("translating"))
|
|
|
+ sm.register_callback("on_enter_completed", lambda e: states.append("completed"))
|
|
|
+
|
|
|
+ # Flow through to TRANSLATING
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+ sm.transition_to(PipelineState.CLEANING)
|
|
|
+ sm.transition_to(PipelineState.TERM_EXTRACTION)
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ assert "translating" in states
|
|
|
+
|
|
|
+ def test_multiple_callbacks_same_event(self):
|
|
|
+ """Test multiple callbacks for the same event."""
|
|
|
+ sm = StateMachine()
|
|
|
+ results = []
|
|
|
+
|
|
|
+ sm.register_callback("on_transition", lambda e: results.append(1))
|
|
|
+ sm.register_callback("on_transition", lambda e: results.append(2))
|
|
|
+ sm.register_callback("on_transition", lambda e: results.append(3))
|
|
|
+
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+
|
|
|
+ assert results == [1, 2, 3]
|
|
|
+
|
|
|
+ def test_unregister_callback(self):
|
|
|
+ """Test unregistering a callback."""
|
|
|
+ sm = StateMachine()
|
|
|
+ results = []
|
|
|
+
|
|
|
+ def callback1(e):
|
|
|
+ results.append(1)
|
|
|
+
|
|
|
+ def callback2(e):
|
|
|
+ results.append(2)
|
|
|
+
|
|
|
+ sm.register_callback("on_transition", callback1)
|
|
|
+ sm.register_callback("on_transition", callback2)
|
|
|
+
|
|
|
+ sm.unregister_callback("on_transition", callback1)
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+
|
|
|
+ assert results == [2]
|
|
|
+
|
|
|
+ def test_unregister_nonexistent_callback(self):
|
|
|
+ """Test unregistering a callback that wasn't registered."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ def callback(e):
|
|
|
+ pass
|
|
|
+
|
|
|
+ result = sm.unregister_callback("on_transition", callback)
|
|
|
+ assert result is False
|
|
|
+
|
|
|
+
|
|
|
+class TestTransitionHistory:
|
|
|
+ """Test transition history tracking."""
|
|
|
+
|
|
|
+ def test_history_records_transitions(self):
|
|
|
+ """Test that transitions are recorded in history."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+ sm.transition_to(PipelineState.CLEANING)
|
|
|
+ sm.transition_to(PipelineState.TERM_EXTRACTION)
|
|
|
+
|
|
|
+ assert len(sm.history) == 3
|
|
|
+
|
|
|
+ def test_history_order(self):
|
|
|
+ """Test that history maintains chronological order."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ states = [
|
|
|
+ PipelineState.FINGERPRINTING,
|
|
|
+ PipelineState.CLEANING,
|
|
|
+ PipelineState.TERM_EXTRACTION,
|
|
|
+ ]
|
|
|
+
|
|
|
+ for state in states:
|
|
|
+ sm.transition_to(state)
|
|
|
+
|
|
|
+ for i, event in enumerate(sm.history):
|
|
|
+ assert event.to_state == states[i]
|
|
|
+
|
|
|
+ def test_history_context_preserved(self):
|
|
|
+ """Test that context during transition is preserved in history."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING, file="test.txt")
|
|
|
+ sm.transition_to(PipelineState.CLEANING, chapters=10)
|
|
|
+
|
|
|
+ assert sm.history[0].context == {"file": "test.txt"}
|
|
|
+ assert sm.history[1].context == {"chapters": 10}
|
|
|
+
|
|
|
+
|
|
|
+class TestReset:
|
|
|
+ """Test state machine reset functionality."""
|
|
|
+
|
|
|
+ def test_reset_to_idle(self):
|
|
|
+ """Test that reset returns state to IDLE."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ sm.reset()
|
|
|
+
|
|
|
+ assert sm.state == PipelineState.IDLE
|
|
|
+
|
|
|
+ def test_reset_clears_context(self):
|
|
|
+ """Test that reset clears context."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.set_context_value("key", "value")
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING)
|
|
|
+
|
|
|
+ sm.reset()
|
|
|
+
|
|
|
+ assert sm.context == {}
|
|
|
+
|
|
|
+ def test_reset_clears_history(self):
|
|
|
+ """Test that reset clears history."""
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+ sm.transition_to(PipelineState.CLEANING)
|
|
|
+
|
|
|
+ sm.reset()
|
|
|
+
|
|
|
+ assert sm.history == []
|