1
0

test_pattern_manager.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. """
  2. Unit tests for pattern_manager parsing logic.
  3. Tests the core pattern file operations:
  4. - Parsing theta-rho files
  5. - Handling comments and empty lines
  6. - Error handling for invalid files
  7. - Listing pattern files
  8. """
  9. import pytest
  10. from unittest.mock import AsyncMock, patch
  11. class TestParseTheTaRhoFile:
  12. """Tests for parse_theta_rho_file function."""
  13. def test_parse_theta_rho_file_valid(self, tmp_path):
  14. """Test parsing a valid theta-rho file."""
  15. # Create test file
  16. test_file = tmp_path / "valid.thr"
  17. test_file.write_text("0.0 0.5\n1.57 0.8\n3.14 0.3\n")
  18. from modules.core.pattern_manager import parse_theta_rho_file
  19. coordinates = parse_theta_rho_file(str(test_file))
  20. assert len(coordinates) == 3
  21. assert coordinates[0] == (0.0, 0.5)
  22. assert coordinates[1] == (1.57, 0.8)
  23. assert coordinates[2] == (3.14, 0.3)
  24. def test_parse_theta_rho_file_with_comments(self, tmp_path):
  25. """Test parsing handles # comments correctly."""
  26. test_file = tmp_path / "commented.thr"
  27. test_file.write_text("""# This is a header comment
  28. 0.0 0.5
  29. # Another comment in the middle
  30. 1.0 0.6
  31. # Trailing comment
  32. """)
  33. from modules.core.pattern_manager import parse_theta_rho_file
  34. coordinates = parse_theta_rho_file(str(test_file))
  35. assert len(coordinates) == 2
  36. assert coordinates[0] == (0.0, 0.5)
  37. assert coordinates[1] == (1.0, 0.6)
  38. def test_parse_theta_rho_file_empty_lines(self, tmp_path):
  39. """Test parsing handles empty lines correctly."""
  40. test_file = tmp_path / "spaced.thr"
  41. test_file.write_text("""0.0 0.5
  42. 1.0 0.6
  43. 2.0 0.7
  44. """)
  45. from modules.core.pattern_manager import parse_theta_rho_file
  46. coordinates = parse_theta_rho_file(str(test_file))
  47. assert len(coordinates) == 3
  48. assert coordinates[0] == (0.0, 0.5)
  49. assert coordinates[1] == (1.0, 0.6)
  50. assert coordinates[2] == (2.0, 0.7)
  51. def test_parse_theta_rho_file_not_found(self, tmp_path):
  52. """Test parsing a non-existent file returns empty list."""
  53. from modules.core.pattern_manager import parse_theta_rho_file
  54. coordinates = parse_theta_rho_file(str(tmp_path / "nonexistent.thr"))
  55. assert coordinates == []
  56. def test_parse_theta_rho_file_invalid_lines(self, tmp_path):
  57. """Test parsing skips invalid lines (non-numeric values)."""
  58. test_file = tmp_path / "invalid.thr"
  59. test_file.write_text("""0.0 0.5
  60. invalid line
  61. 1.0 0.6
  62. not a number here
  63. 2.0 0.7
  64. """)
  65. from modules.core.pattern_manager import parse_theta_rho_file
  66. coordinates = parse_theta_rho_file(str(test_file))
  67. # Should only get the valid lines
  68. assert len(coordinates) == 3
  69. assert coordinates[0] == (0.0, 0.5)
  70. assert coordinates[1] == (1.0, 0.6)
  71. assert coordinates[2] == (2.0, 0.7)
  72. def test_parse_theta_rho_file_whitespace_handling(self, tmp_path):
  73. """Test parsing handles various whitespace correctly."""
  74. test_file = tmp_path / "whitespace.thr"
  75. test_file.write_text(""" 0.0 0.5
  76. 1.0 0.6
  77. 0.0 0.5
  78. """)
  79. from modules.core.pattern_manager import parse_theta_rho_file
  80. coordinates = parse_theta_rho_file(str(test_file))
  81. assert len(coordinates) == 3
  82. def test_parse_theta_rho_file_scientific_notation(self, tmp_path):
  83. """Test parsing handles scientific notation."""
  84. test_file = tmp_path / "scientific.thr"
  85. test_file.write_text("""1.5e-3 0.5
  86. 3.14159 1.0e0
  87. """)
  88. from modules.core.pattern_manager import parse_theta_rho_file
  89. coordinates = parse_theta_rho_file(str(test_file))
  90. assert len(coordinates) == 2
  91. assert coordinates[0][0] == pytest.approx(0.0015)
  92. assert coordinates[1][1] == pytest.approx(1.0)
  93. def test_parse_theta_rho_file_negative_values(self, tmp_path):
  94. """Test parsing handles negative values."""
  95. test_file = tmp_path / "negative.thr"
  96. test_file.write_text("""-3.14 0.5
  97. 0.0 -0.5
  98. -1.0 -0.3
  99. """)
  100. from modules.core.pattern_manager import parse_theta_rho_file
  101. coordinates = parse_theta_rho_file(str(test_file))
  102. assert len(coordinates) == 3
  103. assert coordinates[0] == (-3.14, 0.5)
  104. assert coordinates[1] == (0.0, -0.5)
  105. assert coordinates[2] == (-1.0, -0.3)
  106. def test_parse_theta_rho_file_only_comments(self, tmp_path):
  107. """Test parsing a file with only comments returns empty list."""
  108. test_file = tmp_path / "comments_only.thr"
  109. test_file.write_text("""# This file only has comments
  110. # No actual coordinates
  111. # Just documentation
  112. """)
  113. from modules.core.pattern_manager import parse_theta_rho_file
  114. coordinates = parse_theta_rho_file(str(test_file))
  115. assert coordinates == []
  116. def test_parse_theta_rho_file_empty_file(self, tmp_path):
  117. """Test parsing an empty file returns empty list."""
  118. test_file = tmp_path / "empty.thr"
  119. test_file.write_text("")
  120. from modules.core.pattern_manager import parse_theta_rho_file
  121. coordinates = parse_theta_rho_file(str(test_file))
  122. assert coordinates == []
  123. class TestListThetaRhoFiles:
  124. """Tests for list_theta_rho_files function."""
  125. def test_list_theta_rho_files_basic(self, tmp_path):
  126. """Test listing pattern files in directory."""
  127. # Create test pattern files
  128. patterns_dir = tmp_path / "patterns"
  129. patterns_dir.mkdir()
  130. (patterns_dir / "circle.thr").write_text("0 0.5")
  131. (patterns_dir / "spiral.thr").write_text("0 0.5")
  132. (patterns_dir / "readme.txt").write_text("not a pattern")
  133. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  134. from modules.core.pattern_manager import list_theta_rho_files
  135. files = list_theta_rho_files()
  136. # Should only list .thr files
  137. assert len(files) == 2
  138. assert "circle.thr" in files
  139. assert "spiral.thr" in files
  140. def test_list_theta_rho_files_subdirectories(self, tmp_path):
  141. """Test listing pattern files in subdirectories."""
  142. patterns_dir = tmp_path / "patterns"
  143. patterns_dir.mkdir()
  144. # Create subdirectory with patterns
  145. subdir = patterns_dir / "custom"
  146. subdir.mkdir()
  147. (subdir / "custom_pattern.thr").write_text("0 0.5")
  148. (patterns_dir / "root_pattern.thr").write_text("0 0.5")
  149. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  150. from modules.core.pattern_manager import list_theta_rho_files
  151. files = list_theta_rho_files()
  152. assert len(files) == 2
  153. assert "root_pattern.thr" in files
  154. # Subdirectory patterns should include relative path
  155. assert "custom/custom_pattern.thr" in files
  156. def test_list_theta_rho_files_skips_cached_images(self, tmp_path):
  157. """Test that cached_images directories are skipped."""
  158. patterns_dir = tmp_path / "patterns"
  159. patterns_dir.mkdir()
  160. # Create cached_images directory with files
  161. cache_dir = patterns_dir / "cached_images"
  162. cache_dir.mkdir()
  163. (cache_dir / "preview.thr").write_text("should be skipped")
  164. (patterns_dir / "real_pattern.thr").write_text("0 0.5")
  165. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  166. from modules.core.pattern_manager import list_theta_rho_files
  167. files = list_theta_rho_files()
  168. # Should only list the real pattern, not cached files
  169. assert len(files) == 1
  170. assert "real_pattern.thr" in files
  171. def test_list_theta_rho_files_empty_directory(self, tmp_path):
  172. """Test listing from empty directory returns empty list."""
  173. patterns_dir = tmp_path / "patterns"
  174. patterns_dir.mkdir()
  175. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  176. from modules.core.pattern_manager import list_theta_rho_files
  177. files = list_theta_rho_files()
  178. assert files == []
  179. class TestGetStatus:
  180. """Tests for get_status function."""
  181. def test_get_status_idle(self, mock_state):
  182. """Test get_status returns expected fields when idle."""
  183. with patch("modules.core.pattern_manager.state", mock_state):
  184. from modules.core.pattern_manager import get_status
  185. status = get_status()
  186. assert "current_file" in status
  187. assert "is_paused" in status
  188. assert "is_running" in status
  189. assert "is_homing" in status
  190. assert "progress" in status
  191. assert "playlist" in status
  192. assert "speed" in status
  193. assert "connection_status" in status
  194. assert status["is_running"] is False
  195. assert status["current_file"] is None
  196. def test_get_status_running_pattern(self, mock_state):
  197. """Test get_status reflects running pattern."""
  198. mock_state.current_playing_file = "test_pattern.thr"
  199. mock_state.stop_requested = False
  200. mock_state.execution_progress = (50, 100, 30.5, 60.0)
  201. with patch("modules.core.pattern_manager.state", mock_state):
  202. from modules.core.pattern_manager import get_status
  203. status = get_status()
  204. assert status["is_running"] is True
  205. assert status["current_file"] == "test_pattern.thr"
  206. assert status["progress"] is not None
  207. assert status["progress"]["current"] == 50
  208. assert status["progress"]["total"] == 100
  209. assert status["progress"]["percentage"] == 50.0
  210. def test_get_status_paused(self, mock_state):
  211. """Test get_status reflects paused state."""
  212. mock_state.pause_requested = True
  213. with patch("modules.core.pattern_manager.state", mock_state):
  214. with patch("modules.core.pattern_manager.is_in_scheduled_pause_period", return_value=False):
  215. from modules.core.pattern_manager import get_status
  216. status = get_status()
  217. assert status["is_paused"] is True
  218. assert status["manual_pause"] is True
  219. def test_get_status_with_playlist(self, mock_state):
  220. """Test get_status includes playlist info when running."""
  221. mock_state.current_playlist = ["a.thr", "b.thr", "c.thr"]
  222. mock_state.current_playlist_name = "test_playlist"
  223. mock_state.current_playlist_index = 1
  224. mock_state.playlist_mode = "indefinite"
  225. with patch("modules.core.pattern_manager.state", mock_state):
  226. from modules.core.pattern_manager import get_status
  227. status = get_status()
  228. assert status["playlist"] is not None
  229. assert status["playlist"]["current_index"] == 1
  230. assert status["playlist"]["total_files"] == 3
  231. assert status["playlist"]["mode"] == "indefinite"
  232. assert status["playlist"]["name"] == "test_playlist"
  233. class TestPlaylistShuffle:
  234. """Tests for playlist shuffle behavior in run_theta_rho_files."""
  235. async def test_shuffle_reshuffles_on_each_repeat_cycle(self, mock_state):
  236. """With shuffle enabled, a repeating playlist must re-shuffle every
  237. cycle instead of replaying the first random order forever."""
  238. playlist = ["a.thr", "b.thr"]
  239. executed = []
  240. shuffle_calls = []
  241. async def fake_run_pattern(file_path, **kwargs):
  242. executed.append(file_path)
  243. if len(executed) >= 3:
  244. mock_state.stop_requested = True
  245. with patch("modules.core.pattern_manager.state", mock_state), \
  246. patch("modules.core.pattern_manager.run_theta_rho_file",
  247. AsyncMock(side_effect=fake_run_pattern)), \
  248. patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
  249. patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()), \
  250. patch("modules.core.pattern_manager.random.shuffle",
  251. side_effect=lambda seq: shuffle_calls.append(list(seq))):
  252. from modules.core.pattern_manager import run_theta_rho_files
  253. await run_theta_rho_files(playlist, run_mode="indefinite", shuffle=True)
  254. # The playlist restarted once (3 patterns executed from a 2-pattern
  255. # playlist), so it must have been shuffled twice: once per cycle.
  256. assert len(executed) == 3
  257. assert len(shuffle_calls) == 2
  258. async def test_no_shuffle_preserves_order(self, mock_state):
  259. """Without shuffle, the playlist order is never touched."""
  260. playlist = ["a.thr", "b.thr"]
  261. executed = []
  262. shuffle_calls = []
  263. async def fake_run_pattern(file_path, **kwargs):
  264. executed.append(file_path)
  265. with patch("modules.core.pattern_manager.state", mock_state), \
  266. patch("modules.core.pattern_manager.run_theta_rho_file",
  267. AsyncMock(side_effect=fake_run_pattern)), \
  268. patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
  269. patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()), \
  270. patch("modules.core.pattern_manager.random.shuffle",
  271. side_effect=lambda seq: shuffle_calls.append(list(seq))):
  272. from modules.core.pattern_manager import run_theta_rho_files
  273. await run_theta_rho_files(playlist, run_mode="single", shuffle=False)
  274. assert executed == ["a.thr", "b.thr"]
  275. assert shuffle_calls == []
  276. class TestIsClearPattern:
  277. """Tests for is_clear_pattern function."""
  278. def test_is_clear_pattern_matches_standard(self):
  279. """Test identifying standard clear patterns."""
  280. from modules.core.pattern_manager import is_clear_pattern
  281. assert is_clear_pattern("./patterns/clear_from_out.thr") is True
  282. assert is_clear_pattern("./patterns/clear_from_in.thr") is True
  283. assert is_clear_pattern("./patterns/clear_sideway.thr") is True
  284. def test_is_clear_pattern_matches_mini(self):
  285. """Test identifying mini table clear patterns."""
  286. from modules.core.pattern_manager import is_clear_pattern
  287. assert is_clear_pattern("./patterns/clear_from_out_mini.thr") is True
  288. assert is_clear_pattern("./patterns/clear_from_in_mini.thr") is True
  289. assert is_clear_pattern("./patterns/clear_sideway_mini.thr") is True
  290. def test_is_clear_pattern_matches_pro(self):
  291. """Test identifying pro table clear patterns."""
  292. from modules.core.pattern_manager import is_clear_pattern
  293. assert is_clear_pattern("./patterns/clear_from_out_pro.thr") is True
  294. assert is_clear_pattern("./patterns/clear_from_in_pro.thr") is True
  295. assert is_clear_pattern("./patterns/clear_sideway_pro.thr") is True
  296. def test_is_clear_pattern_rejects_regular_patterns(self):
  297. """Test that regular patterns are not identified as clear patterns."""
  298. from modules.core.pattern_manager import is_clear_pattern
  299. assert is_clear_pattern("./patterns/circle.thr") is False
  300. assert is_clear_pattern("./patterns/spiral.thr") is False
  301. assert is_clear_pattern("./patterns/custom/my_pattern.thr") is False
  302. class TestPlaylistRunModes:
  303. """Tests for run_theta_rho_files repeat behavior.
  304. The manual "Run Playlist" dialog sends run_mode="indefinite", while
  305. auto-play and MQTT send run_mode="loop". Both must repeat the playlist;
  306. "single" must stop after one pass.
  307. """
  308. PLAYLIST = ["a.thr", "b.thr"]
  309. async def _run_playlist(self, mock_state, run_mode, stop_after):
  310. """Run a playlist with a stubbed pattern executor.
  311. Requests a stop once the executor has run stop_after patterns (so
  312. repeat modes can't spin forever), then returns the executed files.
  313. """
  314. executed = []
  315. async def fake_run_pattern(file_path, **kwargs):
  316. executed.append(file_path)
  317. if len(executed) >= stop_after:
  318. mock_state.stop_requested = True
  319. with patch("modules.core.pattern_manager.state", mock_state), \
  320. patch("modules.core.pattern_manager.run_theta_rho_file",
  321. AsyncMock(side_effect=fake_run_pattern)), \
  322. patch("modules.core.pattern_manager.broadcast_progress", AsyncMock()), \
  323. patch("modules.core.pattern_manager.start_idle_led_timeout", AsyncMock()):
  324. from modules.core.pattern_manager import run_theta_rho_files
  325. await run_theta_rho_files(list(self.PLAYLIST), run_mode=run_mode)
  326. return executed
  327. async def test_run_mode_single_stops_after_one_pass(self, mock_state):
  328. """A "single" playlist runs each pattern once and completes."""
  329. executed = await self._run_playlist(mock_state, "single", stop_after=10)
  330. assert executed == ["a.thr", "b.thr"]
  331. async def test_run_mode_indefinite_restarts_playlist(self, mock_state):
  332. """An "indefinite" playlist restarts after the last pattern."""
  333. executed = await self._run_playlist(mock_state, "indefinite", stop_after=3)
  334. assert executed == ["a.thr", "b.thr", "a.thr"]
  335. async def test_run_mode_loop_restarts_playlist(self, mock_state):
  336. """A "loop" playlist (auto-play/MQTT) restarts after the last pattern."""
  337. executed = await self._run_playlist(mock_state, "loop", stop_after=3)
  338. assert executed == ["a.thr", "b.thr", "a.thr"]