| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- """
- Unit tests for state machine persistence.
- Tests cover saving, loading, atomic writes, and error handling.
- """
- import json
- import tempfile
- from pathlib import Path
- import pytest
- from src.core.states import PipelineState
- from src.core.state_machine import StateMachine, TransitionEvent
- from src.core.persistence import (
- StateMachinePersistence,
- StateMachinePersistenceError,
- PersistentStateMachine,
- PersistentTransitionEvent,
- )
- class TestPersistentTransitionEvent:
- """Test PersistentTransitionEvent dataclass."""
- def test_creation(self):
- """Test creating a PersistentTransitionEvent."""
- event = PersistentTransitionEvent(
- from_state="idle",
- to_state="fingerprinting",
- context={"file": "test.txt"},
- )
- assert event.from_state == "idle"
- assert event.to_state == "fingerprinting"
- assert event.context == {"file": "test.txt"}
- def test_default_timestamp(self):
- """Test that timestamp is set automatically."""
- event = PersistentTransitionEvent(
- from_state="idle",
- to_state="fingerprinting",
- )
- assert event.timestamp != ""
- assert len(event.timestamp) > 0
- class TestPersistentStateMachine:
- """Test PersistentStateMachine dataclass."""
- def test_default_values(self):
- """Test default values."""
- psm = PersistentStateMachine()
- assert psm.state == "idle"
- assert psm.context == {}
- assert psm.history == []
- assert psm.metadata == {}
- def test_to_dict(self):
- """Test converting to dictionary."""
- psm = PersistentStateMachine(
- state="translating",
- context={"progress": 50},
- history=[
- PersistentTransitionEvent(
- from_state="idle",
- to_state="translating",
- )
- ],
- )
- data = psm.to_dict()
- assert data["state"] == "translating"
- assert data["context"]["progress"] == 50
- assert len(data["history"]) == 1
- def test_from_dict(self):
- """Test creating from dictionary."""
- data = {
- "version": "1.0",
- "state": "translating",
- "context": {"progress": 50},
- "history": [
- {
- "from_state": "idle",
- "to_state": "translating",
- "context": {},
- "timestamp": "2026-03-15T10:00:00",
- }
- ],
- "metadata": {},
- }
- psm = PersistentStateMachine.from_dict(data)
- assert psm.state == "translating"
- assert psm.context["progress"] == 50
- assert len(psm.history) == 1
- class TestStateMachinePersistence:
- """Test StateMachinePersistence class."""
- def test_init_with_path(self):
- """Test initialization with path."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- persistence = StateMachinePersistence(path)
- assert persistence.state_file == path
- def test_save_and_load(self):
- """Test saving and loading state machine."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- # Create and configure state machine
- sm = StateMachine()
- sm.transition_to(PipelineState.FINGERPRINTING, file="novel.txt")
- sm.transition_to(PipelineState.CLEANING, mode="deep")
- # Save
- persistence = StateMachinePersistence(path)
- persistence.save(sm)
- # Load
- loaded_data = persistence.load()
- assert loaded_data is not None
- assert loaded_data.state == "cleaning"
- assert loaded_data.context["file"] == "novel.txt"
- assert loaded_data.context["mode"] == "deep"
- assert len(loaded_data.history) == 2
- def test_save_creates_parent_directories(self):
- """Test that save creates parent directories."""
- with tempfile.TemporaryDirectory() as tmpdir:
- nested_path = Path(tmpdir) / "nested" / "dir" / "state.json"
- sm = StateMachine()
- persistence = StateMachinePersistence(nested_path)
- persistence.save(sm)
- assert nested_path.exists()
- def test_save_creates_valid_json(self):
- """Test that save creates valid JSON file."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- sm.transition_to(PipelineState.TRANSLATING, progress=75)
- persistence = StateMachinePersistence(path)
- persistence.save(sm)
- # Read and verify JSON
- with open(path, "r") as f:
- data = json.load(f)
- assert data["state"] == "translating"
- assert data["context"]["progress"] == 75
- def test_atomic_write(self):
- """Test that save uses atomic write (temp file + rename)."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- persistence = StateMachinePersistence(path)
- persistence.save(sm)
- # Temp file should not exist after successful save
- temp_file = path.with_suffix(path.suffix + ".tmp")
- assert not temp_file.exists()
- # Final file should exist
- assert path.exists()
- def test_load_nonexistent_file(self):
- """Test loading a non-existent file returns None."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "nonexistent.json"
- persistence = StateMachinePersistence(path)
- result = persistence.load()
- assert result is None
- def test_load_invalid_json(self):
- """Test loading invalid JSON raises error."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "invalid.json"
- # Write invalid JSON
- with open(path, "w") as f:
- f.write("{ invalid json }")
- persistence = StateMachinePersistence(path)
- with pytest.raises(StateMachinePersistenceError):
- persistence.load()
- def test_load_and_restore(self):
- """Test load_and_restore method."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- # Save original state
- sm1 = StateMachine()
- sm1.transition_to(PipelineState.TRANSLATING, progress=50)
- persistence = StateMachinePersistence(path)
- persistence.save(sm1)
- # Restore into new state machine
- sm2 = StateMachine()
- result = persistence.load_and_restore(sm2)
- assert result is True
- assert sm2.state == PipelineState.TRANSLATING
- assert sm2.get_context_value("progress") == 50
- assert len(sm2.history) == 1
- def test_load_and_restore_nonexistent(self):
- """Test load_and_restore with non-existent file."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "nonexistent.json"
- sm = StateMachine()
- persistence = StateMachinePersistence(path)
- result = persistence.load_and_restore(sm)
- assert result is False
- assert sm.state == PipelineState.IDLE
- def test_exists(self):
- """Test exists method."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- persistence = StateMachinePersistence(path)
- assert persistence.exists() is False
- sm = StateMachine()
- persistence.save(sm)
- assert persistence.exists() is True
- def test_delete(self):
- """Test delete method."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- persistence = StateMachinePersistence(path)
- persistence.save(sm)
- assert path.exists()
- assert persistence.delete() is True
- assert not path.exists()
- def test_delete_nonexistent(self):
- """Test deleting non-existent file."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "nonexistent.json"
- persistence = StateMachinePersistence(path)
- result = persistence.delete()
- assert result is False
- class TestStateMachinePersistenceMethods:
- """Test StateMachine save_to_file and load_from_file methods."""
- def test_save_to_file(self):
- """Test StateMachine.save_to_file method."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- sm.transition_to(PipelineState.UPLOADING, target="web")
- sm.save_to_file(path)
- # Verify file was created
- assert path.exists()
- # Verify content
- with open(path, "r") as f:
- data = json.load(f)
- assert data["state"] == "uploading"
- assert data["context"]["target"] == "web"
- def test_load_from_file(self):
- """Test StateMachine.load_from_file class method."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- # Save original
- sm1 = StateMachine()
- sm1.transition_to(PipelineState.COMPLETED, output="/path/to/file.txt")
- sm1.save_to_file(path)
- # Load
- sm2 = StateMachine.load_from_file(path)
- assert sm2 is not None
- assert sm2.state == PipelineState.COMPLETED
- assert sm2.get_context_value("output") == "/path/to/file.txt"
- def test_load_from_file_nonexistent(self):
- """Test load_from_file with non-existent file."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "nonexistent.json"
- sm = StateMachine.load_from_file(path)
- assert sm is None
- def test_roundtrip_preserves_all_data(self):
- """Test that save/load roundtrip preserves all data."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- # Create state machine with various states
- sm1 = StateMachine()
- sm1.transition_to(PipelineState.FINGERPRINTING, file="novel.txt")
- sm1.transition_to(PipelineState.CLEANING)
- sm1.transition_to(PipelineState.TERM_EXTRACTION, terms=5)
- sm1.transition_to(PipelineState.TRANSLATING, progress=25, chapter=1)
- sm1.transition_to(PipelineState.TRANSLATING, progress=50, chapter=2)
- # Save and load
- sm1.save_to_file(path)
- sm2 = StateMachine.load_from_file(path)
- # Verify all data
- assert sm2.state == sm1.state
- assert sm2.context == sm1.context
- assert len(sm2.history) == len(sm1.history)
- for i, (h1, h2) in enumerate(zip(sm1.history, sm2.history)):
- assert h1.from_state == h2.from_state
- assert h1.to_state == h2.to_state
- assert h1.context == h2.context
- class TestPersistenceEdgeCases:
- """Test edge cases and error conditions."""
- def test_save_empty_state_machine(self):
- """Test saving an empty (initial) state machine."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- sm.save_to_file(path)
- sm2 = StateMachine.load_from_file(path)
- assert sm2 is not None
- assert sm2.state == PipelineState.IDLE
- assert sm2.context == {}
- assert sm2.history == []
- def test_save_with_large_context(self):
- """Test saving with large context data."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- large_context = {f"key_{i}": f"value_{i}" * 100 for i in range(50)}
- sm.transition_to(PipelineState.TRANSLATING, **large_context)
- sm.save_to_file(path)
- sm2 = StateMachine.load_from_file(path)
- assert sm2 is not None
- assert len(sm2.context) == 50
- def test_version_field_preserved(self):
- """Test that version field is saved and can be checked."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- sm.save_to_file(path)
- with open(path, "r") as f:
- data = json.load(f)
- assert "version" in data
- assert data["version"] == "1.0"
- def test_metadata_includes_saved_at(self):
- """Test that metadata includes timestamp."""
- with tempfile.TemporaryDirectory() as tmpdir:
- path = Path(tmpdir) / "state.json"
- sm = StateMachine()
- sm.save_to_file(path)
- persistence = StateMachinePersistence(path)
- data = persistence.load()
- assert "metadata" in data.to_dict()
- assert "saved_at" in data.metadata
- assert data.metadata["saved_at"] != ""
|