test_core_state_machine.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """
  2. Unit tests for StateMachine implementation in src/core.
  3. Tests cover state transitions, callbacks, context management,
  4. and error handling.
  5. """
  6. import pytest
  7. from src.core.states import PipelineState
  8. from src.core.state_machine import (
  9. StateMachine,
  10. StateMachineError,
  11. InvalidTransitionError,
  12. TransitionEvent,
  13. )
  14. class TestStateMachineInitialization:
  15. """Test StateMachine initialization and basic properties."""
  16. def test_initial_state_is_idle(self):
  17. """Test that state machine starts in IDLE state."""
  18. sm = StateMachine()
  19. assert sm.state == PipelineState.IDLE
  20. def test_context_is_empty_on_init(self):
  21. """Test that context is empty on initialization."""
  22. sm = StateMachine()
  23. assert sm.context == {}
  24. assert len(sm.context) == 0
  25. def test_history_is_empty_on_init(self):
  26. """Test that history is empty on initialization."""
  27. sm = StateMachine()
  28. assert sm.history == []
  29. assert len(sm.history) == 0
  30. def test_get_state_info(self):
  31. """Test getting comprehensive state information."""
  32. sm = StateMachine()
  33. info = sm.get_state_info()
  34. assert info["current_state"] == "idle"
  35. assert info["is_terminal"] is False
  36. assert info["is_active"] is False
  37. assert info["transition_count"] == 0
  38. assert "fingerprinting" in info["allowed_transitions"]
  39. class TestValidTransitions:
  40. """Test valid state transitions."""
  41. def test_idle_to_fingerprinting(self):
  42. """Test IDLE -> FINGERPRINTING transition."""
  43. sm = StateMachine()
  44. result = sm.transition_to(PipelineState.FINGERPRINTING)
  45. assert result is True
  46. assert sm.state == PipelineState.FINGERPRINTING
  47. def test_normal_pipeline_flow(self):
  48. """Test complete normal flow through the pipeline."""
  49. sm = StateMachine()
  50. flow = [
  51. PipelineState.FINGERPRINTING,
  52. PipelineState.CLEANING,
  53. PipelineState.TERM_EXTRACTION,
  54. PipelineState.TRANSLATING,
  55. PipelineState.UPLOADING,
  56. PipelineState.COMPLETED,
  57. ]
  58. for target_state in flow:
  59. result = sm.transition_to(target_state)
  60. assert result is True, f"Failed to transition to {target_state}"
  61. assert sm.state == target_state
  62. def test_transition_to_failed(self):
  63. """Test transition from active state to FAILED."""
  64. sm = StateMachine()
  65. sm.transition_to(PipelineState.FINGERPRINTING)
  66. result = sm.transition_to(PipelineState.FAILED)
  67. assert result is True
  68. assert sm.state == PipelineState.FAILED
  69. def test_failed_to_idle(self):
  70. """Test reset from FAILED to IDLE."""
  71. sm = StateMachine()
  72. sm.transition_to(PipelineState.FINGERPRINTING)
  73. sm.transition_to(PipelineState.FAILED)
  74. result = sm.transition_to(PipelineState.IDLE)
  75. assert result is True
  76. assert sm.state == PipelineState.IDLE
  77. def test_pause_and_resume(self):
  78. """Test pausing from active state and resuming."""
  79. sm = StateMachine()
  80. sm.transition_to(PipelineState.TRANSLATING)
  81. # Pause
  82. assert sm.transition_to(PipelineState.PAUSED) is True
  83. assert sm.state == PipelineState.PAUSED
  84. # Resume
  85. assert sm.transition_to(PipelineState.TRANSLATING) is True
  86. assert sm.state == PipelineState.TRANSLATING
  87. class TestInvalidTransitions:
  88. """Test invalid state transitions."""
  89. def test_idle_direct_to_translating(self):
  90. """Test that IDLE cannot transition directly to TRANSLATING."""
  91. sm = StateMachine()
  92. result = sm.transition_to(PipelineState.TRANSLATING)
  93. assert result is False
  94. assert sm.state == PipelineState.IDLE
  95. def test_completed_to_active_state(self):
  96. """Test that COMPLETED cannot transition directly to active states."""
  97. sm = StateMachine()
  98. # Complete the pipeline
  99. for state in [
  100. PipelineState.FINGERPRINTING,
  101. PipelineState.CLEANING,
  102. PipelineState.TERM_EXTRACTION,
  103. PipelineState.TRANSLATING,
  104. PipelineState.UPLOADING,
  105. PipelineState.COMPLETED,
  106. ]:
  107. sm.transition_to(state)
  108. # Try to go directly to TRANSLATING
  109. result = sm.transition_to(PipelineState.TRANSLATING)
  110. assert result is False
  111. assert sm.state == PipelineState.COMPLETED
  112. def test_backward_transition(self):
  113. """Test that backward transitions are not allowed."""
  114. sm = StateMachine()
  115. sm.transition_to(PipelineState.TRANSLATING)
  116. # Try to go back to CLEANING
  117. result = sm.transition_to(PipelineState.CLEANING)
  118. assert result is False
  119. assert sm.state == PipelineState.TRANSLATING
  120. def test_can_transition_to(self):
  121. """Test can_transition_to method."""
  122. sm = StateMachine()
  123. assert sm.can_transition_to(PipelineState.FINGERPRINTING) is True
  124. assert sm.can_transition_to(PipelineState.TRANSLATING) is False
  125. sm.transition_to(PipelineState.TRANSLATING)
  126. assert sm.can_transition_to(PipelineState.UPLOADING) is True
  127. assert sm.can_transition_to(PipelineState.CLEANING) is False
  128. class TestTransitionOrRaise:
  129. """Test transition_or_raise method."""
  130. def test_valid_transition_with_or_raise(self):
  131. """Test valid transition with transition_or_raise."""
  132. sm = StateMachine()
  133. sm.transition_or_raise(PipelineState.FINGERPRINTING)
  134. assert sm.state == PipelineState.FINGERPRINTING
  135. def test_invalid_transition_raises_error(self):
  136. """Test that invalid transition raises InvalidTransitionError."""
  137. sm = StateMachine()
  138. with pytest.raises(InvalidTransitionError) as exc_info:
  139. sm.transition_or_raise(PipelineState.TRANSLATING)
  140. assert exc_info.value.from_state == PipelineState.IDLE
  141. assert exc_info.value.to_state == PipelineState.TRANSLATING
  142. class TestContextManagement:
  143. """Test context storage and retrieval."""
  144. def test_context_update_on_transition(self):
  145. """Test that context is updated during transition."""
  146. sm = StateMachine()
  147. sm.transition_to(PipelineState.FINGERPRINTING, file_path="/test.txt")
  148. assert sm.get_context_value("file_path") == "/test.txt"
  149. def test_set_and_get_context_value(self):
  150. """Test setting and getting context values."""
  151. sm = StateMachine()
  152. sm.set_context_value("key1", "value1")
  153. sm.set_context_value("key2", 123)
  154. assert sm.get_context_value("key1") == "value1"
  155. assert sm.get_context_value("key2") == 123
  156. assert sm.get_context_value("key3", "default") == "default"
  157. def test_context_returns_copy(self):
  158. """Test that context property returns a copy."""
  159. sm = StateMachine()
  160. sm.set_context_value("key", "value")
  161. context1 = sm.context
  162. context2 = sm.context
  163. # Modifying returned dict should not affect internal state
  164. context1["key"] = "modified"
  165. assert sm.get_context_value("key") == "value"
  166. assert context2["key"] == "value"
  167. def test_clear_context(self):
  168. """Test clearing context."""
  169. sm = StateMachine()
  170. sm.set_context_value("key1", "value1")
  171. sm.set_context_value("key2", "value2")
  172. sm.clear_context()
  173. assert sm.context == {}
  174. assert sm.get_context_value("key1") is None
  175. assert sm.get_context_value("key2") is None
  176. class TestCallbacks:
  177. """Test callback system."""
  178. def test_on_transition_callback(self):
  179. """Test on_transition callback."""
  180. sm = StateMachine()
  181. events = []
  182. def callback(event):
  183. events.append(event)
  184. sm.register_callback("on_transition", callback)
  185. sm.transition_to(PipelineState.FINGERPRINTING)
  186. assert len(events) == 1
  187. assert events[0].from_state == PipelineState.IDLE
  188. assert events[0].to_state == PipelineState.FINGERPRINTING
  189. def test_on_enter_state_callback(self):
  190. """Test on_enter_<state> callbacks."""
  191. sm = StateMachine()
  192. states = []
  193. sm.register_callback("on_enter_translating", lambda e: states.append("translating"))
  194. sm.register_callback("on_enter_completed", lambda e: states.append("completed"))
  195. # Flow through to TRANSLATING
  196. sm.transition_to(PipelineState.FINGERPRINTING)
  197. sm.transition_to(PipelineState.CLEANING)
  198. sm.transition_to(PipelineState.TERM_EXTRACTION)
  199. sm.transition_to(PipelineState.TRANSLATING)
  200. assert "translating" in states
  201. def test_multiple_callbacks_same_event(self):
  202. """Test multiple callbacks for the same event."""
  203. sm = StateMachine()
  204. results = []
  205. sm.register_callback("on_transition", lambda e: results.append(1))
  206. sm.register_callback("on_transition", lambda e: results.append(2))
  207. sm.register_callback("on_transition", lambda e: results.append(3))
  208. sm.transition_to(PipelineState.FINGERPRINTING)
  209. assert results == [1, 2, 3]
  210. def test_unregister_callback(self):
  211. """Test unregistering a callback."""
  212. sm = StateMachine()
  213. results = []
  214. def callback1(e):
  215. results.append(1)
  216. def callback2(e):
  217. results.append(2)
  218. sm.register_callback("on_transition", callback1)
  219. sm.register_callback("on_transition", callback2)
  220. sm.unregister_callback("on_transition", callback1)
  221. sm.transition_to(PipelineState.FINGERPRINTING)
  222. assert results == [2]
  223. def test_unregister_nonexistent_callback(self):
  224. """Test unregistering a callback that wasn't registered."""
  225. sm = StateMachine()
  226. def callback(e):
  227. pass
  228. result = sm.unregister_callback("on_transition", callback)
  229. assert result is False
  230. class TestTransitionHistory:
  231. """Test transition history tracking."""
  232. def test_history_records_transitions(self):
  233. """Test that transitions are recorded in history."""
  234. sm = StateMachine()
  235. sm.transition_to(PipelineState.FINGERPRINTING)
  236. sm.transition_to(PipelineState.CLEANING)
  237. sm.transition_to(PipelineState.TERM_EXTRACTION)
  238. assert len(sm.history) == 3
  239. def test_history_order(self):
  240. """Test that history maintains chronological order."""
  241. sm = StateMachine()
  242. states = [
  243. PipelineState.FINGERPRINTING,
  244. PipelineState.CLEANING,
  245. PipelineState.TERM_EXTRACTION,
  246. ]
  247. for state in states:
  248. sm.transition_to(state)
  249. for i, event in enumerate(sm.history):
  250. assert event.to_state == states[i]
  251. def test_history_context_preserved(self):
  252. """Test that context during transition is preserved in history."""
  253. sm = StateMachine()
  254. sm.transition_to(PipelineState.FINGERPRINTING, file="test.txt")
  255. sm.transition_to(PipelineState.CLEANING, chapters=10)
  256. assert sm.history[0].context == {"file": "test.txt"}
  257. assert sm.history[1].context == {"chapters": 10}
  258. class TestReset:
  259. """Test state machine reset functionality."""
  260. def test_reset_to_idle(self):
  261. """Test that reset returns state to IDLE."""
  262. sm = StateMachine()
  263. sm.transition_to(PipelineState.TRANSLATING)
  264. sm.reset()
  265. assert sm.state == PipelineState.IDLE
  266. def test_reset_clears_context(self):
  267. """Test that reset clears context."""
  268. sm = StateMachine()
  269. sm.set_context_value("key", "value")
  270. sm.transition_to(PipelineState.TRANSLATING)
  271. sm.reset()
  272. assert sm.context == {}
  273. def test_reset_clears_history(self):
  274. """Test that reset clears history."""
  275. sm = StateMachine()
  276. sm.transition_to(PipelineState.FINGERPRINTING)
  277. sm.transition_to(PipelineState.CLEANING)
  278. sm.reset()
  279. assert sm.history == []