|
|
@@ -396,3 +396,258 @@ class TestPersistenceEdgeCases:
|
|
|
assert "metadata" in data.to_dict()
|
|
|
assert "saved_at" in data.metadata
|
|
|
assert data.metadata["saved_at"] != ""
|
|
|
+
|
|
|
+
|
|
|
+class TestStateValidation:
|
|
|
+ """Test state validation functionality."""
|
|
|
+
|
|
|
+ def test_validate_on_restore_valid(self):
|
|
|
+ """Test validation of valid state machine."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING, progress=50)
|
|
|
+ sm.save_to_file(path)
|
|
|
+
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+ assert sm2.validate_on_restore() is True
|
|
|
+
|
|
|
+ def test_validate_on_restore_empty(self):
|
|
|
+ """Test validation of empty state machine."""
|
|
|
+ sm = StateMachine()
|
|
|
+ assert sm.validate_on_restore() is True
|
|
|
+
|
|
|
+ def test_validate_on_restored_with_complete_flow(self):
|
|
|
+ """Test validation of complete pipeline flow."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm = StateMachine()
|
|
|
+ for state in [
|
|
|
+ PipelineState.FINGERPRINTING,
|
|
|
+ PipelineState.CLEANING,
|
|
|
+ PipelineState.TERM_EXTRACTION,
|
|
|
+ PipelineState.TRANSLATING,
|
|
|
+ PipelineState.UPLOADING,
|
|
|
+ PipelineState.COMPLETED,
|
|
|
+ ]:
|
|
|
+ sm.transition_to(state)
|
|
|
+
|
|
|
+ sm.save_to_file(path)
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+
|
|
|
+ assert sm2.validate_on_restore() is True
|
|
|
+
|
|
|
+ def test_get_resume_point(self):
|
|
|
+ """Test getting resume point description."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ # Test active state
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING, progress=75)
|
|
|
+ sm.save_to_file(path)
|
|
|
+
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+ resume_point = sm2.get_resume_point()
|
|
|
+ assert "Translating" in resume_point
|
|
|
+
|
|
|
+ # Test terminal state
|
|
|
+ sm3 = StateMachine()
|
|
|
+ sm3.transition_to(PipelineState.COMPLETED)
|
|
|
+ assert "completed" in sm3.get_resume_point().lower()
|
|
|
+
|
|
|
+ # Test idle state
|
|
|
+ sm4 = StateMachine()
|
|
|
+ assert "Ready to start" in sm4.get_resume_point()
|
|
|
+
|
|
|
+ def test_validate_detects_invalid_state(self):
|
|
|
+ """Test validation detects manually corrupted state."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ # Manually corrupt the state
|
|
|
+ sm._state = "invalid_state"
|
|
|
+
|
|
|
+ assert sm.validate_on_restore() is False
|
|
|
+
|
|
|
+ def test_validate_detects_invalid_history(self):
|
|
|
+ """Test validation detects invalid history transitions."""
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ # Manually add invalid history entry
|
|
|
+ from src.core.state_machine import TransitionEvent
|
|
|
+ sm._history.append(
|
|
|
+ TransitionEvent(
|
|
|
+ from_state=PipelineState.IDLE,
|
|
|
+ to_state=PipelineState.TRANSLATING, # Invalid: IDLE can't go to TRANSLATING
|
|
|
+ context={},
|
|
|
+ )
|
|
|
+ )
|
|
|
+ sm._state = PipelineState.TRANSLATING
|
|
|
+
|
|
|
+ assert sm.validate_on_restore() is False
|
|
|
+
|
|
|
+
|
|
|
+class TestPersistenceEdgeCases:
|
|
|
+ """Test edge cases for persistence."""
|
|
|
+
|
|
|
+ def test_save_with_special_characters_in_context(self):
|
|
|
+ """Test saving with special characters in context values."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(
|
|
|
+ PipelineState.TRANSLATING,
|
|
|
+ text="Hello\nWorld\t!",
|
|
|
+ path="C:\\Users\\Test",
|
|
|
+ quote='Test "quoted" string',
|
|
|
+ )
|
|
|
+ sm.save_to_file(path)
|
|
|
+
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+ assert sm2.context["text"] == "Hello\nWorld\t!"
|
|
|
+ assert sm2.context["path"] == "C:\\Users\\Test"
|
|
|
+
|
|
|
+ def test_save_with_unicode(self):
|
|
|
+ """Test saving with Unicode characters."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm = StateMachine()
|
|
|
+ sm.transition_to(
|
|
|
+ PipelineState.TRANSLATING,
|
|
|
+ chinese="林风是主角",
|
|
|
+ emoji="😀🎉",
|
|
|
+ mixed="Hello 世界 🌍",
|
|
|
+ )
|
|
|
+ sm.save_to_file(path)
|
|
|
+
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+ assert sm2.context["chinese"] == "林风是主角"
|
|
|
+ assert sm2.context["emoji"] == "😀🎉"
|
|
|
+ assert sm2.context["mixed"] == "Hello 世界 🌍"
|
|
|
+
|
|
|
+ def test_overwrite_existing_state_file(self):
|
|
|
+ """Test overwriting an existing state file."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ # Save first state
|
|
|
+ sm1 = StateMachine()
|
|
|
+ sm1.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+ sm1.save_to_file(path)
|
|
|
+
|
|
|
+ # Overwrite with new state
|
|
|
+ sm2 = StateMachine()
|
|
|
+ sm2.transition_to(PipelineState.UPLOADING, target="web")
|
|
|
+ sm2.save_to_file(path)
|
|
|
+
|
|
|
+ # Load should get the new state
|
|
|
+ sm3 = StateMachine.load_from_file(path)
|
|
|
+ assert sm3.state == PipelineState.UPLOADING
|
|
|
+ assert sm3.context["target"] == "web"
|
|
|
+
|
|
|
+ def test_save_load_cycle_preserves_callbacks_config(self):
|
|
|
+ """Test that callbacks are not persisted (as expected)."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm1 = StateMachine()
|
|
|
+ sm1.register_callback("on_transition", lambda e: None)
|
|
|
+ sm1.transition_to(PipelineState.TRANSLATING)
|
|
|
+ sm1.save_to_file(path)
|
|
|
+
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+ # Loaded machine should have no callbacks registered
|
|
|
+ assert len(sm2._callbacks) == 0
|
|
|
+ # But state should be preserved
|
|
|
+ assert sm2.state == PipelineState.TRANSLATING
|
|
|
+
|
|
|
+
|
|
|
+class TestLargeScalePersistence:
|
|
|
+ """Test persistence with larger data sets."""
|
|
|
+
|
|
|
+ def test_large_history(self):
|
|
|
+ """Test saving and loading with large history."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm = StateMachine()
|
|
|
+
|
|
|
+ # Create a back-and-forth pattern
|
|
|
+ for i in range(50):
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING, iteration=i)
|
|
|
+ sm.transition_to(PipelineState.UPLOADING)
|
|
|
+ sm.transition_to(PipelineState.COMPLETED)
|
|
|
+ sm._state = PipelineState.IDLE # Reset for next iteration
|
|
|
+ sm.transition_to(PipelineState.FINGERPRINTING)
|
|
|
+
|
|
|
+ sm.save_to_file(path)
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+
|
|
|
+ assert len(sm2.history) == len(sm.history)
|
|
|
+ assert sm2.context["iteration"] == 49 # Last iteration
|
|
|
+
|
|
|
+ def test_many_context_entries(self):
|
|
|
+ """Test saving with many context entries."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ path = Path(tmpdir) / "state.json"
|
|
|
+
|
|
|
+ sm = StateMachine()
|
|
|
+ large_context = {f"key_{i}": f"value_{i}" for i in range(100)}
|
|
|
+ sm.transition_to(PipelineState.TRANSLATING, **large_context)
|
|
|
+ sm.save_to_file(path)
|
|
|
+
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+ assert len(sm2.context) == 100
|
|
|
+ assert sm2.context["key_0"] == "value_0"
|
|
|
+ assert sm2.context["key_99"] == "value_99"
|
|
|
+
|
|
|
+
|
|
|
+class TestPersistenceWithDifferentStates:
|
|
|
+ """Test persistence across different pipeline states."""
|
|
|
+
|
|
|
+ def test_persist_from_each_state(self):
|
|
|
+ """Test saving and restoring from each possible state."""
|
|
|
+ with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
+ for state in PipelineState:
|
|
|
+ path = Path(tmpdir) / f"state_{state.value}.json"
|
|
|
+
|
|
|
+ sm1 = StateMachine()
|
|
|
+
|
|
|
+ # Flow to the target state
|
|
|
+ if state != PipelineState.IDLE:
|
|
|
+ # Find a path to the state
|
|
|
+ if state == PipelineState.PAUSED:
|
|
|
+ sm1.transition_to(PipelineState.TRANSLATING)
|
|
|
+ sm1.transition_to(PipelineState.PAUSED)
|
|
|
+ elif state == PipelineState.FAILED:
|
|
|
+ sm1.transition_to(PipelineState.TRANSLATING)
|
|
|
+ sm1.transition_to(PipelineState.FAILED)
|
|
|
+ elif state == PipelineState.COMPLETED:
|
|
|
+ for s in [
|
|
|
+ PipelineState.FINGERPRINTING,
|
|
|
+ PipelineState.CLEANING,
|
|
|
+ PipelineState.TERM_EXTRACTION,
|
|
|
+ PipelineState.TRANSLATING,
|
|
|
+ PipelineState.UPLOADING,
|
|
|
+ PipelineState.COMPLETED,
|
|
|
+ ]:
|
|
|
+ sm1.transition_to(s)
|
|
|
+ else:
|
|
|
+ # For other states, try direct flow
|
|
|
+ try:
|
|
|
+ sm1.transition_to(state)
|
|
|
+ except:
|
|
|
+ pass # Skip if not reachable directly
|
|
|
+
|
|
|
+ sm1.save_to_file(path)
|
|
|
+ sm2 = StateMachine.load_from_file(path)
|
|
|
+
|
|
|
+ assert sm2 is not None
|
|
|
+ if sm1.state == state: # Only check if we successfully reached the state
|
|
|
+ assert sm2.state == state
|
|
|
+ assert sm2.validate_on_restore()
|
|
|
+
|