test_playlist.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. """
  2. Integration tests for playlist functionality.
  3. These tests verify playlist playback modes, clear patterns,
  4. pause between patterns, and state updates.
  5. Run with: pytest tests/integration/test_playlist.py --run-hardware -v
  6. """
  7. import pytest
  8. import time
  9. import asyncio
  10. import os
  11. @pytest.fixture
  12. def test_playlist(run_hardware):
  13. """Create a test playlist and clean it up after the test."""
  14. if not run_hardware:
  15. pytest.skip("Hardware tests disabled")
  16. from modules.core import playlist_manager
  17. playlist_name = "_integration_test_playlist"
  18. # Find available patterns
  19. pattern_dir = './patterns'
  20. available_patterns = []
  21. for f in os.listdir(pattern_dir):
  22. if f.endswith('.thr') and not f.startswith('.'):
  23. path = os.path.join(pattern_dir, f)
  24. if os.path.isfile(path):
  25. available_patterns.append(path)
  26. if len(available_patterns) >= 3:
  27. break
  28. if len(available_patterns) < 2:
  29. pytest.skip("Need at least 2 patterns for playlist tests")
  30. # Create the playlist
  31. playlist_manager.create_playlist(playlist_name, available_patterns)
  32. yield {
  33. "name": playlist_name,
  34. "patterns": available_patterns,
  35. "count": len(available_patterns)
  36. }
  37. # Cleanup
  38. playlist_manager.delete_playlist(playlist_name)
  39. @pytest.mark.hardware
  40. @pytest.mark.slow
  41. class TestPlaylistModes:
  42. """Tests for different playlist run modes."""
  43. def test_run_playlist_single_mode(self, hardware_port, run_hardware, test_playlist):
  44. """Test playlist in single mode - plays all patterns once then stops."""
  45. from modules.connection import connection_manager
  46. from modules.core import pattern_manager, playlist_manager
  47. from modules.core.state import state
  48. conn = connection_manager.SerialConnection(hardware_port)
  49. state.conn = conn
  50. try:
  51. # Run playlist in single mode
  52. async def run():
  53. success, msg = await playlist_manager.run_playlist(
  54. test_playlist["name"],
  55. pause_time=1,
  56. run_mode="single"
  57. )
  58. return success
  59. loop = asyncio.get_event_loop()
  60. # Start playlist
  61. task = asyncio.ensure_future(run())
  62. # Wait for it to start
  63. time.sleep(3)
  64. assert state.current_playlist is not None, "Playlist should be running"
  65. assert state.playlist_mode == "single", f"Mode should be 'single', got: {state.playlist_mode}"
  66. print(f"Playlist running in single mode with {test_playlist['count']} patterns")
  67. # Stop after verifying mode
  68. loop.run_until_complete(pattern_manager.stop_actions())
  69. finally:
  70. conn.close()
  71. state.conn = None
  72. def test_run_playlist_loop_mode(self, hardware_port, run_hardware, test_playlist):
  73. """Test playlist in loop mode - continues from start after last pattern."""
  74. from modules.connection import connection_manager
  75. from modules.core import pattern_manager, playlist_manager
  76. from modules.core.state import state
  77. conn = connection_manager.SerialConnection(hardware_port)
  78. state.conn = conn
  79. try:
  80. async def run():
  81. success, msg = await playlist_manager.run_playlist(
  82. test_playlist["name"],
  83. pause_time=1,
  84. run_mode="loop"
  85. )
  86. return success
  87. loop = asyncio.get_event_loop()
  88. asyncio.ensure_future(run())
  89. time.sleep(3)
  90. assert state.playlist_mode == "loop", f"Mode should be 'loop', got: {state.playlist_mode}"
  91. print("Playlist running in loop mode")
  92. loop.run_until_complete(pattern_manager.stop_actions())
  93. finally:
  94. conn.close()
  95. state.conn = None
  96. def test_run_playlist_shuffle(self, hardware_port, run_hardware, test_playlist):
  97. """Test playlist shuffle mode randomizes order."""
  98. from modules.connection import connection_manager
  99. from modules.core import pattern_manager, playlist_manager
  100. from modules.core.state import state
  101. conn = connection_manager.SerialConnection(hardware_port)
  102. state.conn = conn
  103. try:
  104. async def run():
  105. success, msg = await playlist_manager.run_playlist(
  106. test_playlist["name"],
  107. pause_time=1,
  108. run_mode="single",
  109. shuffle=True
  110. )
  111. return success
  112. loop = asyncio.get_event_loop()
  113. asyncio.ensure_future(run())
  114. time.sleep(3)
  115. # Playlist should be running
  116. assert state.current_playlist is not None
  117. print(f"Playlist running with shuffle enabled")
  118. print(f"Current pattern: {state.current_playing_file}")
  119. print(f"Playlist order: {state.current_playlist}")
  120. loop.run_until_complete(pattern_manager.stop_actions())
  121. finally:
  122. conn.close()
  123. state.conn = None
  124. @pytest.mark.hardware
  125. @pytest.mark.slow
  126. class TestPlaylistPause:
  127. """Tests for pause time between patterns."""
  128. def test_playlist_pause_between_patterns(self, hardware_port, run_hardware, test_playlist):
  129. """Test that pause_time is respected between patterns."""
  130. from modules.connection import connection_manager
  131. from modules.core import pattern_manager, playlist_manager
  132. from modules.core.state import state
  133. conn = connection_manager.SerialConnection(hardware_port)
  134. state.conn = conn
  135. try:
  136. pause_time = 5 # 5 seconds between patterns
  137. async def run():
  138. success, msg = await playlist_manager.run_playlist(
  139. test_playlist["name"],
  140. pause_time=pause_time,
  141. run_mode="single"
  142. )
  143. return success
  144. loop = asyncio.get_event_loop()
  145. asyncio.ensure_future(run())
  146. # Wait for first pattern to complete (this may take a while)
  147. # For testing, we'll just verify the pause_time setting is stored
  148. time.sleep(3)
  149. # Check that pause_time_remaining is used during transitions
  150. # (We can't easily wait for pattern completion in a test)
  151. print(f"Playlist started with pause_time={pause_time}s")
  152. print(f"Current pause_time_remaining: {state.pause_time_remaining}")
  153. loop.run_until_complete(pattern_manager.stop_actions())
  154. finally:
  155. conn.close()
  156. state.conn = None
  157. def test_stop_during_playlist_pause(self, hardware_port, run_hardware, test_playlist):
  158. """Test that stop works during the pause between patterns."""
  159. from modules.connection import connection_manager
  160. from modules.core import pattern_manager, playlist_manager
  161. from modules.core.state import state
  162. conn = connection_manager.SerialConnection(hardware_port)
  163. state.conn = conn
  164. try:
  165. # Use a short pattern and long pause
  166. async def run():
  167. success, msg = await playlist_manager.run_playlist(
  168. test_playlist["name"],
  169. pause_time=30, # Long pause
  170. run_mode="single"
  171. )
  172. return success
  173. loop = asyncio.get_event_loop()
  174. asyncio.ensure_future(run())
  175. time.sleep(3)
  176. # Stop (whether during pattern or pause)
  177. async def do_stop():
  178. return await pattern_manager.stop_actions()
  179. success = loop.run_until_complete(do_stop())
  180. assert success, "Stop should succeed"
  181. time.sleep(0.5)
  182. assert state.current_playlist is None, "Playlist should be stopped"
  183. print("Successfully stopped during playlist")
  184. finally:
  185. conn.close()
  186. state.conn = None
  187. @pytest.mark.hardware
  188. @pytest.mark.slow
  189. class TestPlaylistClearPattern:
  190. """Tests for clear pattern functionality between patterns."""
  191. def test_playlist_with_clear_pattern(self, hardware_port, run_hardware, test_playlist):
  192. """Test that clear pattern runs between main patterns."""
  193. from modules.connection import connection_manager
  194. from modules.core import pattern_manager, playlist_manager
  195. from modules.core.state import state
  196. conn = connection_manager.SerialConnection(hardware_port)
  197. state.conn = conn
  198. try:
  199. # Use "clear_from_in" which clears from center outward
  200. async def run():
  201. success, msg = await playlist_manager.run_playlist(
  202. test_playlist["name"],
  203. pause_time=1,
  204. clear_pattern="clear_from_in",
  205. run_mode="single"
  206. )
  207. return success
  208. loop = asyncio.get_event_loop()
  209. asyncio.ensure_future(run())
  210. time.sleep(3)
  211. assert state.current_playlist is not None
  212. print("Playlist running with clear_pattern='clear_from_in'")
  213. loop.run_until_complete(pattern_manager.stop_actions())
  214. finally:
  215. conn.close()
  216. state.conn = None
  217. @pytest.mark.hardware
  218. @pytest.mark.slow
  219. class TestPlaylistStateUpdates:
  220. """Tests for state updates during playlist playback."""
  221. def test_current_file_updates(self, hardware_port, run_hardware, test_playlist):
  222. """Test that current_playing_file reflects the active pattern."""
  223. from modules.connection import connection_manager
  224. from modules.core import pattern_manager, playlist_manager
  225. from modules.core.state import state
  226. conn = connection_manager.SerialConnection(hardware_port)
  227. state.conn = conn
  228. try:
  229. async def run():
  230. success, msg = await playlist_manager.run_playlist(
  231. test_playlist["name"],
  232. pause_time=1,
  233. run_mode="single"
  234. )
  235. return success
  236. loop = asyncio.get_event_loop()
  237. asyncio.ensure_future(run())
  238. time.sleep(3)
  239. # current_playing_file should be set
  240. assert state.current_playing_file is not None, \
  241. "current_playing_file should be set during playback"
  242. # Should be one of the playlist patterns
  243. current = state.current_playing_file
  244. print(f"Current playing file: {current}")
  245. # Normalize paths for comparison
  246. playlist_patterns = [os.path.normpath(p) for p in test_playlist["patterns"]]
  247. current_normalized = os.path.normpath(current) if current else None
  248. # The current file should be related to one of the playlist patterns
  249. # (path may differ slightly based on how it's resolved)
  250. assert current is not None, "Should have a current playing file"
  251. loop.run_until_complete(pattern_manager.stop_actions())
  252. finally:
  253. conn.close()
  254. state.conn = None
  255. def test_playlist_index_updates(self, hardware_port, run_hardware, test_playlist):
  256. """Test that current_playlist_index updates correctly."""
  257. from modules.connection import connection_manager
  258. from modules.core import pattern_manager, playlist_manager
  259. from modules.core.state import state
  260. conn = connection_manager.SerialConnection(hardware_port)
  261. state.conn = conn
  262. try:
  263. async def run():
  264. success, msg = await playlist_manager.run_playlist(
  265. test_playlist["name"],
  266. pause_time=1,
  267. run_mode="single"
  268. )
  269. return success
  270. loop = asyncio.get_event_loop()
  271. asyncio.ensure_future(run())
  272. time.sleep(3)
  273. # Index should be set
  274. assert state.current_playlist_index is not None, \
  275. "current_playlist_index should be set"
  276. assert state.current_playlist_index >= 0, \
  277. "Index should be non-negative"
  278. print(f"Current playlist index: {state.current_playlist_index}")
  279. print(f"Playlist length: {len(state.current_playlist) if state.current_playlist else 0}")
  280. loop.run_until_complete(pattern_manager.stop_actions())
  281. finally:
  282. conn.close()
  283. state.conn = None
  284. def test_progress_updates(self, hardware_port, run_hardware):
  285. """Test that execution_progress updates during pattern execution."""
  286. from modules.connection import connection_manager
  287. from modules.core import pattern_manager
  288. from modules.core.state import state
  289. conn = connection_manager.SerialConnection(hardware_port)
  290. state.conn = conn
  291. try:
  292. pattern_path = './patterns/star.thr'
  293. async def run():
  294. await pattern_manager.run_theta_rho_file(pattern_path)
  295. loop = asyncio.get_event_loop()
  296. asyncio.ensure_future(run())
  297. # Wait for pattern to start
  298. time.sleep(2)
  299. # Check progress
  300. progress_samples = []
  301. for _ in range(5):
  302. if state.execution_progress:
  303. progress_samples.append(state.execution_progress)
  304. print(f"Progress: {state.execution_progress}")
  305. time.sleep(1)
  306. # Should have captured some progress
  307. assert len(progress_samples) > 0, "Should have recorded some progress updates"
  308. # Progress should be changing (pattern executing)
  309. if len(progress_samples) > 1:
  310. first = progress_samples[0]
  311. last = progress_samples[-1]
  312. # Progress is typically a dict with 'current' and 'total'
  313. if isinstance(first, dict) and isinstance(last, dict):
  314. print(f"Progress went from {first} to {last}")
  315. loop.run_until_complete(pattern_manager.stop_actions())
  316. finally:
  317. conn.close()
  318. state.conn = None
  319. @pytest.mark.hardware
  320. class TestWebSocketStatus:
  321. """Tests for WebSocket status updates during playback."""
  322. def test_status_updates_during_playback(self, hardware_port, run_hardware):
  323. """Test that WebSocket broadcasts correct state during playback."""
  324. if not run_hardware:
  325. pytest.skip("Hardware tests disabled")
  326. from fastapi.testclient import TestClient
  327. from modules.connection import connection_manager
  328. from modules.core import pattern_manager
  329. from modules.core.state import state
  330. from main import app
  331. conn = connection_manager.SerialConnection(hardware_port)
  332. state.conn = conn
  333. try:
  334. pattern_path = './patterns/star.thr'
  335. # Start pattern
  336. async def start():
  337. asyncio.create_task(pattern_manager.run_theta_rho_file(pattern_path))
  338. loop = asyncio.get_event_loop()
  339. loop.run_until_complete(start())
  340. time.sleep(2)
  341. # Check WebSocket status
  342. client = TestClient(app)
  343. with client.websocket_connect("/ws/status") as websocket:
  344. data = websocket.receive_json()
  345. print(f"WebSocket status: {data}")
  346. # Should reflect running state
  347. # The exact fields depend on your broadcast_status implementation
  348. assert isinstance(data, dict), "Status should be a dict"
  349. loop.run_until_complete(pattern_manager.stop_actions())
  350. finally:
  351. conn.close()
  352. state.conn = None