1
0

test_playlist.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  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 threading
  10. import os
  11. def start_playlist_async(client, playlist_name, pause_time=1, run_mode="single",
  12. clear_pattern=None, shuffle=False):
  13. """Helper to start a playlist in a background thread.
  14. Returns the thread so caller can join() it after stopping.
  15. """
  16. def run():
  17. payload = {
  18. "playlist_name": playlist_name,
  19. "pause_time": pause_time,
  20. "run_mode": run_mode
  21. }
  22. if clear_pattern:
  23. payload["clear_pattern"] = clear_pattern
  24. if shuffle:
  25. payload["shuffle"] = shuffle
  26. client.post("/run_playlist", json=payload)
  27. thread = threading.Thread(target=run)
  28. thread.start()
  29. return thread
  30. def start_pattern_async(client, file_name="star.thr"):
  31. """Helper to start a pattern in a background thread.
  32. Returns the thread so caller can join() it after stopping.
  33. """
  34. def run():
  35. client.post("/run_theta_rho", json={"file_name": file_name})
  36. thread = threading.Thread(target=run)
  37. thread.start()
  38. return thread
  39. def stop_pattern(client):
  40. """Helper to stop pattern execution.
  41. Uses force_stop which doesn't wait for locks (avoids event loop issues in tests).
  42. """
  43. response = client.post("/force_stop")
  44. return response
  45. @pytest.fixture
  46. def test_playlist(run_hardware):
  47. """Create a test playlist and clean it up after the test."""
  48. if not run_hardware:
  49. pytest.skip("Hardware tests disabled")
  50. from modules.core import playlist_manager
  51. playlist_name = "_integration_test_playlist"
  52. # Use specific simple patterns for testing
  53. test_patterns = [
  54. "star.thr",
  55. "circle_normalized.thr",
  56. "square.thr"
  57. ]
  58. # Verify patterns exist
  59. available_patterns = []
  60. for pattern in test_patterns:
  61. if os.path.exists(f"./patterns/{pattern}"):
  62. available_patterns.append(pattern)
  63. if len(available_patterns) < 2:
  64. pytest.skip(f"Need at least 2 of these patterns: {test_patterns}")
  65. # Create the playlist
  66. playlist_manager.create_playlist(playlist_name, available_patterns)
  67. yield {
  68. "name": playlist_name,
  69. "patterns": available_patterns,
  70. "count": len(available_patterns)
  71. }
  72. # Cleanup
  73. playlist_manager.delete_playlist(playlist_name)
  74. @pytest.mark.hardware
  75. @pytest.mark.slow
  76. class TestPlaylistModes:
  77. """Tests for different playlist run modes."""
  78. def test_run_playlist_single_mode(self, hardware_port, run_hardware, test_playlist):
  79. """Test playlist in single mode - plays all patterns once then stops."""
  80. from modules.connection import connection_manager
  81. from modules.core.state import state
  82. from fastapi.testclient import TestClient
  83. from main import app
  84. conn = connection_manager.SerialConnection(hardware_port)
  85. state.conn = conn
  86. try:
  87. client = TestClient(app)
  88. print(f"Test playlist: {test_playlist}")
  89. # Try direct API call first to see response
  90. response = client.post("/run_playlist", json={
  91. "playlist_name": test_playlist["name"],
  92. "pause_time": 1,
  93. "run_mode": "single"
  94. })
  95. print(f"API response: {response.status_code} - {response.text}")
  96. # Wait for it to start
  97. time.sleep(3)
  98. print(f"state.current_playlist = {state.current_playlist}")
  99. print(f"state.playlist_mode = {state.playlist_mode}")
  100. print(f"state.current_playing_file = {state.current_playing_file}")
  101. assert state.current_playlist is not None, "Playlist should be running"
  102. assert state.playlist_mode == "single", f"Mode should be 'single', got: {state.playlist_mode}"
  103. print(f"Playlist running in single mode with {test_playlist['count']} patterns")
  104. # Clean up
  105. stop_pattern(client)
  106. finally:
  107. conn.close()
  108. state.conn = None
  109. def test_run_playlist_loop_mode(self, hardware_port, run_hardware, test_playlist):
  110. """Test playlist in loop mode - continues from start after last pattern."""
  111. from modules.connection import connection_manager
  112. from modules.core.state import state
  113. from fastapi.testclient import TestClient
  114. from main import app
  115. conn = connection_manager.SerialConnection(hardware_port)
  116. state.conn = conn
  117. try:
  118. client = TestClient(app)
  119. # Start playlist in background
  120. playlist_thread = start_playlist_async(
  121. client,
  122. test_playlist["name"],
  123. pause_time=1,
  124. run_mode="loop"
  125. )
  126. time.sleep(3)
  127. assert state.playlist_mode == "loop", f"Mode should be 'loop', got: {state.playlist_mode}"
  128. print("Playlist running in loop mode")
  129. # Clean up
  130. stop_pattern(client)
  131. playlist_thread.join(timeout=5)
  132. finally:
  133. conn.close()
  134. state.conn = None
  135. def test_run_playlist_shuffle(self, hardware_port, run_hardware, test_playlist):
  136. """Test playlist shuffle mode randomizes order."""
  137. from modules.connection import connection_manager
  138. from modules.core.state import state
  139. from fastapi.testclient import TestClient
  140. from main import app
  141. conn = connection_manager.SerialConnection(hardware_port)
  142. state.conn = conn
  143. try:
  144. client = TestClient(app)
  145. # Start playlist in background with shuffle
  146. playlist_thread = start_playlist_async(
  147. client,
  148. test_playlist["name"],
  149. pause_time=1,
  150. run_mode="single",
  151. shuffle=True
  152. )
  153. time.sleep(3)
  154. # Playlist should be running
  155. assert state.current_playlist is not None
  156. print(f"Playlist running with shuffle enabled")
  157. print(f"Current pattern: {state.current_playing_file}")
  158. print(f"Playlist order: {state.current_playlist}")
  159. # Clean up
  160. stop_pattern(client)
  161. playlist_thread.join(timeout=5)
  162. finally:
  163. conn.close()
  164. state.conn = None
  165. @pytest.mark.hardware
  166. @pytest.mark.slow
  167. class TestPlaylistPause:
  168. """Tests for pause time between patterns."""
  169. def test_playlist_pause_between_patterns(self, hardware_port, run_hardware, test_playlist):
  170. """Test that pause_time is respected between patterns."""
  171. from modules.connection import connection_manager
  172. from modules.core.state import state
  173. from fastapi.testclient import TestClient
  174. from main import app
  175. conn = connection_manager.SerialConnection(hardware_port)
  176. state.conn = conn
  177. try:
  178. client = TestClient(app)
  179. pause_time = 5 # 5 seconds between patterns
  180. # Start playlist in background
  181. playlist_thread = start_playlist_async(
  182. client,
  183. test_playlist["name"],
  184. pause_time=pause_time,
  185. run_mode="single"
  186. )
  187. # Wait for first pattern to complete (this may take a while)
  188. # For testing, we'll just verify the pause_time setting is stored
  189. time.sleep(3)
  190. # Check that pause_time_remaining is used during transitions
  191. # (We can't easily wait for pattern completion in a test)
  192. print(f"Playlist started with pause_time={pause_time}s")
  193. print(f"Current pause_time_remaining: {state.pause_time_remaining}")
  194. # Clean up
  195. stop_pattern(client)
  196. playlist_thread.join(timeout=5)
  197. finally:
  198. conn.close()
  199. state.conn = None
  200. def test_stop_during_playlist_pause(self, hardware_port, run_hardware, test_playlist):
  201. """Test that stop works during the pause between patterns."""
  202. from modules.connection import connection_manager
  203. from modules.core.state import state
  204. from fastapi.testclient import TestClient
  205. from main import app
  206. conn = connection_manager.SerialConnection(hardware_port)
  207. state.conn = conn
  208. try:
  209. client = TestClient(app)
  210. # Start playlist with long pause
  211. playlist_thread = start_playlist_async(
  212. client,
  213. test_playlist["name"],
  214. pause_time=30, # Long pause
  215. run_mode="single"
  216. )
  217. time.sleep(3)
  218. # Stop (whether during pattern or pause)
  219. response = stop_pattern(client)
  220. assert response.status_code == 200, f"Stop failed: {response.text}"
  221. time.sleep(0.5)
  222. assert state.current_playlist is None, "Playlist should be stopped"
  223. print("Successfully stopped during playlist")
  224. playlist_thread.join(timeout=5)
  225. finally:
  226. conn.close()
  227. state.conn = None
  228. @pytest.mark.hardware
  229. @pytest.mark.slow
  230. class TestPlaylistClearPattern:
  231. """Tests for clear pattern functionality between patterns."""
  232. def test_playlist_with_clear_pattern(self, hardware_port, run_hardware, test_playlist):
  233. """Test that clear pattern runs between main patterns."""
  234. from modules.connection import connection_manager
  235. from modules.core.state import state
  236. from fastapi.testclient import TestClient
  237. from main import app
  238. conn = connection_manager.SerialConnection(hardware_port)
  239. state.conn = conn
  240. try:
  241. client = TestClient(app)
  242. # Start playlist with clear pattern
  243. playlist_thread = start_playlist_async(
  244. client,
  245. test_playlist["name"],
  246. pause_time=1,
  247. clear_pattern="clear_from_in",
  248. run_mode="single"
  249. )
  250. time.sleep(3)
  251. assert state.current_playlist is not None
  252. print("Playlist running with clear_pattern='clear_from_in'")
  253. # Clean up
  254. stop_pattern(client)
  255. playlist_thread.join(timeout=5)
  256. finally:
  257. conn.close()
  258. state.conn = None
  259. @pytest.mark.hardware
  260. @pytest.mark.slow
  261. class TestPlaylistStateUpdates:
  262. """Tests for state updates during playlist playback."""
  263. def test_current_file_updates(self, hardware_port, run_hardware, test_playlist):
  264. """Test that current_playing_file reflects the active pattern."""
  265. from modules.connection import connection_manager
  266. from modules.core.state import state
  267. from fastapi.testclient import TestClient
  268. from main import app
  269. conn = connection_manager.SerialConnection(hardware_port)
  270. state.conn = conn
  271. try:
  272. client = TestClient(app)
  273. # Start playlist in background
  274. playlist_thread = start_playlist_async(
  275. client,
  276. test_playlist["name"],
  277. pause_time=1,
  278. run_mode="single"
  279. )
  280. time.sleep(3)
  281. # current_playing_file should be set
  282. assert state.current_playing_file is not None, \
  283. "current_playing_file should be set during playback"
  284. # Should be one of the playlist patterns
  285. current = state.current_playing_file
  286. print(f"Current playing file: {current}")
  287. # Normalize paths for comparison
  288. playlist_patterns = [os.path.normpath(p) for p in test_playlist["patterns"]]
  289. current_normalized = os.path.normpath(current) if current else None
  290. # The current file should be related to one of the playlist patterns
  291. # (path may differ slightly based on how it's resolved)
  292. assert current is not None, "Should have a current playing file"
  293. # Clean up
  294. stop_pattern(client)
  295. playlist_thread.join(timeout=5)
  296. finally:
  297. conn.close()
  298. state.conn = None
  299. def test_playlist_index_updates(self, hardware_port, run_hardware, test_playlist):
  300. """Test that current_playlist_index updates correctly."""
  301. from modules.connection import connection_manager
  302. from modules.core.state import state
  303. from fastapi.testclient import TestClient
  304. from main import app
  305. conn = connection_manager.SerialConnection(hardware_port)
  306. state.conn = conn
  307. try:
  308. client = TestClient(app)
  309. # Start playlist in background
  310. playlist_thread = start_playlist_async(
  311. client,
  312. test_playlist["name"],
  313. pause_time=1,
  314. run_mode="single"
  315. )
  316. time.sleep(3)
  317. # Index should be set
  318. assert state.current_playlist_index is not None, \
  319. "current_playlist_index should be set"
  320. assert state.current_playlist_index >= 0, \
  321. "Index should be non-negative"
  322. print(f"Current playlist index: {state.current_playlist_index}")
  323. print(f"Playlist length: {len(state.current_playlist) if state.current_playlist else 0}")
  324. # Clean up
  325. stop_pattern(client)
  326. playlist_thread.join(timeout=5)
  327. finally:
  328. conn.close()
  329. state.conn = None
  330. def test_progress_updates(self, hardware_port, run_hardware):
  331. """Test that execution_progress updates during pattern execution."""
  332. from modules.connection import connection_manager
  333. from modules.core.state import state
  334. from fastapi.testclient import TestClient
  335. from main import app
  336. conn = connection_manager.SerialConnection(hardware_port)
  337. state.conn = conn
  338. try:
  339. client = TestClient(app)
  340. # Start pattern in background
  341. pattern_thread = start_pattern_async(client, "star.thr")
  342. # Wait for pattern to start
  343. time.sleep(2)
  344. # Check progress
  345. progress_samples = []
  346. for _ in range(5):
  347. if state.execution_progress:
  348. progress_samples.append(state.execution_progress)
  349. print(f"Progress: {state.execution_progress}")
  350. time.sleep(1)
  351. # Should have captured some progress
  352. assert len(progress_samples) > 0, "Should have recorded some progress updates"
  353. # Progress should be changing (pattern executing)
  354. if len(progress_samples) > 1:
  355. first = progress_samples[0]
  356. last = progress_samples[-1]
  357. # Progress is typically a dict with 'current' and 'total'
  358. if isinstance(first, dict) and isinstance(last, dict):
  359. print(f"Progress went from {first} to {last}")
  360. # Clean up
  361. stop_pattern(client)
  362. pattern_thread.join(timeout=5)
  363. finally:
  364. conn.close()
  365. state.conn = None
  366. @pytest.mark.hardware
  367. class TestWebSocketStatus:
  368. """Tests for WebSocket status updates during playback."""
  369. def test_status_updates_during_playback(self, hardware_port, run_hardware):
  370. """Test that WebSocket broadcasts correct state during playback."""
  371. if not run_hardware:
  372. pytest.skip("Hardware tests disabled")
  373. from fastapi.testclient import TestClient
  374. from modules.connection import connection_manager
  375. from modules.core.state import state
  376. from main import app
  377. conn = connection_manager.SerialConnection(hardware_port)
  378. state.conn = conn
  379. try:
  380. client = TestClient(app)
  381. # Start pattern in background
  382. pattern_thread = start_pattern_async(client, "star.thr")
  383. time.sleep(2)
  384. # Check WebSocket status
  385. with client.websocket_connect("/ws/status") as websocket:
  386. message = websocket.receive_json()
  387. # Status format is {'type': 'status_update', 'data': {...}}
  388. assert message.get("type") == "status_update", \
  389. f"Expected type='status_update', got: {message}"
  390. data = message.get("data", {})
  391. print(f"WebSocket status: {data}")
  392. # Should have expected status fields
  393. assert "is_running" in data, f"Expected 'is_running' in data"
  394. # Clean up
  395. stop_pattern(client)
  396. pattern_thread.join(timeout=5)
  397. finally:
  398. conn.close()
  399. state.conn = None