test_playback_controls.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. """
  2. Integration tests for playback controls.
  3. These tests verify pause, resume, stop, skip, and speed control functionality
  4. with real hardware connected.
  5. Run with: pytest tests/integration/test_playback_controls.py --run-hardware -v
  6. """
  7. import pytest
  8. import time
  9. import asyncio
  10. import os
  11. @pytest.mark.hardware
  12. @pytest.mark.slow
  13. class TestPauseResume:
  14. """Tests for pause and resume functionality."""
  15. def test_pause_during_pattern(self, hardware_port, run_hardware):
  16. """Test pausing execution mid-pattern.
  17. Verifies:
  18. 1. Pattern starts executing
  19. 2. Pause request is acknowledged
  20. 3. Ball actually stops moving
  21. """
  22. if not run_hardware:
  23. pytest.skip("Hardware tests disabled")
  24. from modules.connection import connection_manager
  25. from modules.core import pattern_manager
  26. from modules.core.state import state
  27. conn = connection_manager.SerialConnection(hardware_port)
  28. state.conn = conn
  29. try:
  30. pattern_path = './patterns/star.thr'
  31. assert os.path.exists(pattern_path), f"Pattern not found: {pattern_path}"
  32. # Start pattern in background
  33. async def start_pattern():
  34. # Run pattern (don't await completion)
  35. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  36. loop = asyncio.get_event_loop()
  37. loop.run_until_complete(start_pattern())
  38. # Wait for pattern to start
  39. time.sleep(2)
  40. assert state.current_playing_file is not None, "Pattern should be running"
  41. # Record position before pause
  42. pos_before = (state.current_theta, state.current_rho)
  43. # Pause execution
  44. result = pattern_manager.pause_execution()
  45. assert result, "Pause should succeed"
  46. assert state.pause_requested, "pause_requested should be True"
  47. # Wait and check ball stopped
  48. time.sleep(1)
  49. pos_after = (state.current_theta, state.current_rho)
  50. # Position should not have changed significantly while paused
  51. theta_diff = abs(pos_after[0] - pos_before[0])
  52. rho_diff = abs(pos_after[1] - pos_before[1])
  53. print(f"Position change during pause: theta={theta_diff:.4f}, rho={rho_diff:.4f}")
  54. # Allow small tolerance for deceleration
  55. assert theta_diff < 0.5, f"Theta should not change much while paused: {theta_diff}"
  56. assert rho_diff < 0.1, f"Rho should not change much while paused: {rho_diff}"
  57. # Clean up - stop the pattern
  58. loop.run_until_complete(pattern_manager.stop_actions())
  59. finally:
  60. conn.close()
  61. state.conn = None
  62. def test_resume_after_pause(self, hardware_port, run_hardware):
  63. """Test resuming execution after pause.
  64. Verifies:
  65. 1. Pattern can be paused
  66. 2. Resume causes movement to continue
  67. 3. Position changes after resume
  68. """
  69. if not run_hardware:
  70. pytest.skip("Hardware tests disabled")
  71. from modules.connection import connection_manager
  72. from modules.core import pattern_manager
  73. from modules.core.state import state
  74. conn = connection_manager.SerialConnection(hardware_port)
  75. state.conn = conn
  76. try:
  77. pattern_path = './patterns/star.thr'
  78. # Start pattern
  79. async def start_pattern():
  80. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  81. loop = asyncio.get_event_loop()
  82. loop.run_until_complete(start_pattern())
  83. time.sleep(2)
  84. # Pause
  85. pattern_manager.pause_execution()
  86. time.sleep(0.5)
  87. pos_paused = (state.current_theta, state.current_rho)
  88. # Resume
  89. result = pattern_manager.resume_execution()
  90. assert result, "Resume should succeed"
  91. assert not state.pause_requested, "pause_requested should be False after resume"
  92. # Wait for movement
  93. time.sleep(2)
  94. pos_resumed = (state.current_theta, state.current_rho)
  95. # Position should have changed after resume
  96. theta_diff = abs(pos_resumed[0] - pos_paused[0])
  97. rho_diff = abs(pos_resumed[1] - pos_paused[1])
  98. print(f"Position change after resume: theta={theta_diff:.4f}, rho={rho_diff:.4f}")
  99. assert theta_diff > 0.1 or rho_diff > 0.05, "Position should change after resume"
  100. # Clean up
  101. loop.run_until_complete(pattern_manager.stop_actions())
  102. finally:
  103. conn.close()
  104. state.conn = None
  105. @pytest.mark.hardware
  106. @pytest.mark.slow
  107. class TestStop:
  108. """Tests for stop functionality."""
  109. def test_stop_during_pattern(self, hardware_port, run_hardware):
  110. """Test stopping execution mid-pattern.
  111. Verifies:
  112. 1. Stop clears current_playing_file
  113. 2. Pattern execution actually stops
  114. """
  115. if not run_hardware:
  116. pytest.skip("Hardware tests disabled")
  117. from modules.connection import connection_manager
  118. from modules.core import pattern_manager
  119. from modules.core.state import state
  120. conn = connection_manager.SerialConnection(hardware_port)
  121. state.conn = conn
  122. try:
  123. pattern_path = './patterns/star.thr'
  124. # Start pattern
  125. async def start_pattern():
  126. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  127. loop = asyncio.get_event_loop()
  128. loop.run_until_complete(start_pattern())
  129. time.sleep(2)
  130. assert state.current_playing_file is not None, "Pattern should be running"
  131. # Stop execution
  132. async def do_stop():
  133. return await pattern_manager.stop_actions()
  134. success = loop.run_until_complete(do_stop())
  135. assert success, "Stop should succeed"
  136. # Verify stopped
  137. time.sleep(0.5)
  138. assert state.current_playing_file is None, "current_playing_file should be None after stop"
  139. assert state.stop_requested, "stop_requested should be True"
  140. print("Stop completed successfully")
  141. finally:
  142. conn.close()
  143. state.conn = None
  144. def test_force_stop(self, hardware_port, run_hardware):
  145. """Test force stop clears all state.
  146. Force stop is a more aggressive stop that clears all pattern state
  147. even if normal stop times out.
  148. """
  149. if not run_hardware:
  150. pytest.skip("Hardware tests disabled")
  151. from modules.connection import connection_manager
  152. from modules.core import pattern_manager
  153. from modules.core.state import state
  154. conn = connection_manager.SerialConnection(hardware_port)
  155. state.conn = conn
  156. try:
  157. pattern_path = './patterns/star.thr'
  158. # Start pattern
  159. async def start_pattern():
  160. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  161. loop = asyncio.get_event_loop()
  162. loop.run_until_complete(start_pattern())
  163. time.sleep(2)
  164. # Force stop by clearing state directly (simulating the /force_stop endpoint)
  165. state.stop_requested = True
  166. state.pause_requested = False
  167. state.current_playing_file = None
  168. state.execution_progress = None
  169. state.is_running = False
  170. state.current_playlist = None
  171. state.current_playlist_index = None
  172. # Wake up waiting tasks
  173. try:
  174. pattern_manager.get_pause_event().set()
  175. except:
  176. pass
  177. time.sleep(0.5)
  178. # Verify all state cleared
  179. assert state.current_playing_file is None
  180. assert state.current_playlist is None
  181. assert state.is_running is False
  182. print("Force stop completed successfully")
  183. finally:
  184. conn.close()
  185. state.conn = None
  186. def test_pause_then_stop(self, hardware_port, run_hardware):
  187. """Test that stop works while paused."""
  188. if not run_hardware:
  189. pytest.skip("Hardware tests disabled")
  190. from modules.connection import connection_manager
  191. from modules.core import pattern_manager
  192. from modules.core.state import state
  193. conn = connection_manager.SerialConnection(hardware_port)
  194. state.conn = conn
  195. try:
  196. pattern_path = './patterns/star.thr'
  197. # Start pattern
  198. async def start_pattern():
  199. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  200. loop = asyncio.get_event_loop()
  201. loop.run_until_complete(start_pattern())
  202. time.sleep(2)
  203. # Pause first
  204. pattern_manager.pause_execution()
  205. time.sleep(0.5)
  206. assert state.pause_requested, "Should be paused"
  207. # Now stop while paused
  208. async def do_stop():
  209. return await pattern_manager.stop_actions()
  210. success = loop.run_until_complete(do_stop())
  211. assert success, "Stop while paused should succeed"
  212. assert state.current_playing_file is None, "Pattern should be stopped"
  213. print("Stop while paused completed successfully")
  214. finally:
  215. conn.close()
  216. state.conn = None
  217. @pytest.mark.hardware
  218. @pytest.mark.slow
  219. class TestSpeedControl:
  220. """Tests for speed control functionality."""
  221. def test_set_speed_during_playback(self, hardware_port, run_hardware):
  222. """Test changing speed during pattern execution.
  223. Verifies speed change is accepted and applied.
  224. """
  225. if not run_hardware:
  226. pytest.skip("Hardware tests disabled")
  227. from modules.connection import connection_manager
  228. from modules.core import pattern_manager
  229. from modules.core.state import state
  230. conn = connection_manager.SerialConnection(hardware_port)
  231. state.conn = conn
  232. try:
  233. pattern_path = './patterns/star.thr'
  234. original_speed = state.speed
  235. # Start pattern
  236. async def start_pattern():
  237. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  238. loop = asyncio.get_event_loop()
  239. loop.run_until_complete(start_pattern())
  240. time.sleep(2)
  241. # Change speed
  242. new_speed = 150
  243. state.speed = new_speed
  244. assert state.speed == new_speed, "Speed should be updated"
  245. print(f"Speed changed from {original_speed} to {new_speed}")
  246. # Let it run at new speed briefly
  247. time.sleep(2)
  248. # Clean up
  249. loop.run_until_complete(pattern_manager.stop_actions())
  250. # Restore original speed
  251. state.speed = original_speed
  252. finally:
  253. conn.close()
  254. state.conn = None
  255. def test_speed_bounds(self, hardware_port, run_hardware):
  256. """Test that invalid speed values are handled correctly."""
  257. if not run_hardware:
  258. pytest.skip("Hardware tests disabled")
  259. from modules.core.state import state
  260. original_speed = state.speed
  261. # Test that speed can be set to valid values
  262. state.speed = 50
  263. assert state.speed == 50
  264. state.speed = 200
  265. assert state.speed == 200
  266. # Note: The API endpoint validates bounds, but state accepts any value
  267. # This test documents current behavior
  268. state.speed = 1
  269. assert state.speed == 1
  270. # Restore
  271. state.speed = original_speed
  272. def test_change_speed_while_paused(self, hardware_port, run_hardware):
  273. """Test changing speed while paused, then resuming."""
  274. if not run_hardware:
  275. pytest.skip("Hardware tests disabled")
  276. from modules.connection import connection_manager
  277. from modules.core import pattern_manager
  278. from modules.core.state import state
  279. conn = connection_manager.SerialConnection(hardware_port)
  280. state.conn = conn
  281. try:
  282. pattern_path = './patterns/star.thr'
  283. original_speed = state.speed
  284. # Start pattern
  285. async def start_pattern():
  286. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  287. loop = asyncio.get_event_loop()
  288. loop.run_until_complete(start_pattern())
  289. time.sleep(2)
  290. # Pause
  291. pattern_manager.pause_execution()
  292. time.sleep(0.5)
  293. # Change speed while paused
  294. new_speed = 180
  295. state.speed = new_speed
  296. print(f"Speed changed to {new_speed} while paused")
  297. # Resume
  298. pattern_manager.resume_execution()
  299. time.sleep(2)
  300. # Verify speed is still the new value
  301. assert state.speed == new_speed, "Speed should persist after resume"
  302. # Clean up
  303. loop.run_until_complete(pattern_manager.stop_actions())
  304. state.speed = original_speed
  305. finally:
  306. conn.close()
  307. state.conn = None
  308. @pytest.mark.hardware
  309. @pytest.mark.slow
  310. class TestSkip:
  311. """Tests for skip pattern functionality."""
  312. def test_skip_pattern_in_playlist(self, hardware_port, run_hardware):
  313. """Test skipping to next pattern in playlist.
  314. Creates a temporary playlist with 2 patterns and verifies
  315. skip moves to the second pattern.
  316. """
  317. if not run_hardware:
  318. pytest.skip("Hardware tests disabled")
  319. from modules.connection import connection_manager
  320. from modules.core import pattern_manager, playlist_manager
  321. from modules.core.state import state
  322. conn = connection_manager.SerialConnection(hardware_port)
  323. state.conn = conn
  324. try:
  325. # Create test playlist with 2 patterns
  326. test_playlist_name = "_test_skip_playlist"
  327. patterns = ["patterns/star.thr", "patterns/circle.thr"]
  328. # Check if both patterns exist
  329. existing_patterns = [p for p in patterns if os.path.exists(p)]
  330. if len(existing_patterns) < 2:
  331. pytest.skip("Need at least 2 patterns for skip test")
  332. playlist_manager.create_playlist(test_playlist_name, existing_patterns)
  333. try:
  334. # Run playlist
  335. async def run_playlist():
  336. await playlist_manager.run_playlist(
  337. test_playlist_name,
  338. pause_time=0,
  339. run_mode="single"
  340. )
  341. loop = asyncio.get_event_loop()
  342. asyncio.ensure_future(run_playlist())
  343. # Wait for first pattern to start
  344. time.sleep(3)
  345. first_pattern = state.current_playing_file
  346. print(f"First pattern: {first_pattern}")
  347. assert first_pattern is not None
  348. # Skip to next pattern
  349. state.skip_requested = True
  350. # Wait for skip to process
  351. time.sleep(3)
  352. second_pattern = state.current_playing_file
  353. print(f"After skip: {second_pattern}")
  354. # Pattern should have changed (or playlist ended)
  355. if second_pattern is not None:
  356. assert second_pattern != first_pattern or state.current_playlist_index > 0, \
  357. "Should have moved to next pattern"
  358. # Clean up
  359. loop.run_until_complete(pattern_manager.stop_actions())
  360. finally:
  361. # Delete test playlist
  362. playlist_manager.delete_playlist(test_playlist_name)
  363. finally:
  364. conn.close()
  365. state.conn = None
  366. def test_skip_while_paused(self, hardware_port, run_hardware):
  367. """Test that skip works while paused."""
  368. if not run_hardware:
  369. pytest.skip("Hardware tests disabled")
  370. from modules.connection import connection_manager
  371. from modules.core import pattern_manager, playlist_manager
  372. from modules.core.state import state
  373. conn = connection_manager.SerialConnection(hardware_port)
  374. state.conn = conn
  375. try:
  376. # Create test playlist
  377. test_playlist_name = "_test_skip_paused"
  378. patterns = ["patterns/star.thr", "patterns/circle.thr"]
  379. existing_patterns = [p for p in patterns if os.path.exists(p)]
  380. if len(existing_patterns) < 2:
  381. pytest.skip("Need at least 2 patterns")
  382. playlist_manager.create_playlist(test_playlist_name, existing_patterns)
  383. try:
  384. # Run playlist
  385. async def run_playlist():
  386. await playlist_manager.run_playlist(test_playlist_name, run_mode="single")
  387. loop = asyncio.get_event_loop()
  388. asyncio.ensure_future(run_playlist())
  389. time.sleep(3)
  390. # Pause
  391. pattern_manager.pause_execution()
  392. time.sleep(0.5)
  393. assert state.pause_requested
  394. first_pattern = state.current_playing_file
  395. # Skip while paused
  396. state.skip_requested = True
  397. # Resume to allow skip to process
  398. pattern_manager.resume_execution()
  399. time.sleep(3)
  400. # Should have moved on
  401. print(f"Skipped from {first_pattern} while paused")
  402. # Clean up
  403. loop.run_until_complete(pattern_manager.stop_actions())
  404. finally:
  405. playlist_manager.delete_playlist(test_playlist_name)
  406. finally:
  407. conn.close()
  408. state.conn = None