1
0

test_pattern_manager.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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 os
  10. import pytest
  11. from unittest.mock import patch, MagicMock
  12. class TestParseTheTaRhoFile:
  13. """Tests for parse_theta_rho_file function."""
  14. def test_parse_theta_rho_file_valid(self, tmp_path):
  15. """Test parsing a valid theta-rho file."""
  16. # Create test file
  17. test_file = tmp_path / "valid.thr"
  18. test_file.write_text("0.0 0.5\n1.57 0.8\n3.14 0.3\n")
  19. from modules.core.pattern_manager import parse_theta_rho_file
  20. coordinates = parse_theta_rho_file(str(test_file))
  21. assert len(coordinates) == 3
  22. assert coordinates[0] == (0.0, 0.5)
  23. assert coordinates[1] == (1.57, 0.8)
  24. assert coordinates[2] == (3.14, 0.3)
  25. def test_parse_theta_rho_file_with_comments(self, tmp_path):
  26. """Test parsing handles # comments correctly."""
  27. test_file = tmp_path / "commented.thr"
  28. test_file.write_text("""# This is a header comment
  29. 0.0 0.5
  30. # Another comment in the middle
  31. 1.0 0.6
  32. # Trailing comment
  33. """)
  34. from modules.core.pattern_manager import parse_theta_rho_file
  35. coordinates = parse_theta_rho_file(str(test_file))
  36. assert len(coordinates) == 2
  37. assert coordinates[0] == (0.0, 0.5)
  38. assert coordinates[1] == (1.0, 0.6)
  39. def test_parse_theta_rho_file_empty_lines(self, tmp_path):
  40. """Test parsing handles empty lines correctly."""
  41. test_file = tmp_path / "spaced.thr"
  42. test_file.write_text("""0.0 0.5
  43. 1.0 0.6
  44. 2.0 0.7
  45. """)
  46. from modules.core.pattern_manager import parse_theta_rho_file
  47. coordinates = parse_theta_rho_file(str(test_file))
  48. assert len(coordinates) == 3
  49. assert coordinates[0] == (0.0, 0.5)
  50. assert coordinates[1] == (1.0, 0.6)
  51. assert coordinates[2] == (2.0, 0.7)
  52. def test_parse_theta_rho_file_not_found(self, tmp_path):
  53. """Test parsing a non-existent file returns empty list."""
  54. from modules.core.pattern_manager import parse_theta_rho_file
  55. coordinates = parse_theta_rho_file(str(tmp_path / "nonexistent.thr"))
  56. assert coordinates == []
  57. def test_parse_theta_rho_file_invalid_lines(self, tmp_path):
  58. """Test parsing skips invalid lines (non-numeric values)."""
  59. test_file = tmp_path / "invalid.thr"
  60. test_file.write_text("""0.0 0.5
  61. invalid line
  62. 1.0 0.6
  63. not a number here
  64. 2.0 0.7
  65. """)
  66. from modules.core.pattern_manager import parse_theta_rho_file
  67. coordinates = parse_theta_rho_file(str(test_file))
  68. # Should only get the valid lines
  69. assert len(coordinates) == 3
  70. assert coordinates[0] == (0.0, 0.5)
  71. assert coordinates[1] == (1.0, 0.6)
  72. assert coordinates[2] == (2.0, 0.7)
  73. def test_parse_theta_rho_file_whitespace_handling(self, tmp_path):
  74. """Test parsing handles various whitespace correctly."""
  75. test_file = tmp_path / "whitespace.thr"
  76. test_file.write_text(""" 0.0 0.5
  77. 1.0 0.6
  78. 0.0 0.5
  79. """)
  80. from modules.core.pattern_manager import parse_theta_rho_file
  81. coordinates = parse_theta_rho_file(str(test_file))
  82. assert len(coordinates) == 3
  83. def test_parse_theta_rho_file_scientific_notation(self, tmp_path):
  84. """Test parsing handles scientific notation."""
  85. test_file = tmp_path / "scientific.thr"
  86. test_file.write_text("""1.5e-3 0.5
  87. 3.14159 1.0e0
  88. """)
  89. from modules.core.pattern_manager import parse_theta_rho_file
  90. coordinates = parse_theta_rho_file(str(test_file))
  91. assert len(coordinates) == 2
  92. assert coordinates[0][0] == pytest.approx(0.0015)
  93. assert coordinates[1][1] == pytest.approx(1.0)
  94. def test_parse_theta_rho_file_negative_values(self, tmp_path):
  95. """Test parsing handles negative values."""
  96. test_file = tmp_path / "negative.thr"
  97. test_file.write_text("""-3.14 0.5
  98. 0.0 -0.5
  99. -1.0 -0.3
  100. """)
  101. from modules.core.pattern_manager import parse_theta_rho_file
  102. coordinates = parse_theta_rho_file(str(test_file))
  103. assert len(coordinates) == 3
  104. assert coordinates[0] == (-3.14, 0.5)
  105. assert coordinates[1] == (0.0, -0.5)
  106. assert coordinates[2] == (-1.0, -0.3)
  107. def test_parse_theta_rho_file_only_comments(self, tmp_path):
  108. """Test parsing a file with only comments returns empty list."""
  109. test_file = tmp_path / "comments_only.thr"
  110. test_file.write_text("""# This file only has comments
  111. # No actual coordinates
  112. # Just documentation
  113. """)
  114. from modules.core.pattern_manager import parse_theta_rho_file
  115. coordinates = parse_theta_rho_file(str(test_file))
  116. assert coordinates == []
  117. def test_parse_theta_rho_file_empty_file(self, tmp_path):
  118. """Test parsing an empty file returns empty list."""
  119. test_file = tmp_path / "empty.thr"
  120. test_file.write_text("")
  121. from modules.core.pattern_manager import parse_theta_rho_file
  122. coordinates = parse_theta_rho_file(str(test_file))
  123. assert coordinates == []
  124. class TestListThetaRhoFiles:
  125. """Tests for list_theta_rho_files function."""
  126. def test_list_theta_rho_files_basic(self, tmp_path):
  127. """Test listing pattern files in directory."""
  128. # Create test pattern files
  129. patterns_dir = tmp_path / "patterns"
  130. patterns_dir.mkdir()
  131. (patterns_dir / "circle.thr").write_text("0 0.5")
  132. (patterns_dir / "spiral.thr").write_text("0 0.5")
  133. (patterns_dir / "readme.txt").write_text("not a pattern")
  134. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  135. from modules.core.pattern_manager import list_theta_rho_files
  136. files = list_theta_rho_files()
  137. # Should only list .thr files
  138. assert len(files) == 2
  139. assert "circle.thr" in files
  140. assert "spiral.thr" in files
  141. def test_list_theta_rho_files_subdirectories(self, tmp_path):
  142. """Test listing pattern files in subdirectories."""
  143. patterns_dir = tmp_path / "patterns"
  144. patterns_dir.mkdir()
  145. # Create subdirectory with patterns
  146. subdir = patterns_dir / "custom"
  147. subdir.mkdir()
  148. (subdir / "custom_pattern.thr").write_text("0 0.5")
  149. (patterns_dir / "root_pattern.thr").write_text("0 0.5")
  150. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  151. from modules.core.pattern_manager import list_theta_rho_files
  152. files = list_theta_rho_files()
  153. assert len(files) == 2
  154. assert "root_pattern.thr" in files
  155. # Subdirectory patterns should include relative path
  156. assert "custom/custom_pattern.thr" in files
  157. def test_list_theta_rho_files_skips_cached_images(self, tmp_path):
  158. """Test that cached_images directories are skipped."""
  159. patterns_dir = tmp_path / "patterns"
  160. patterns_dir.mkdir()
  161. # Create cached_images directory with files
  162. cache_dir = patterns_dir / "cached_images"
  163. cache_dir.mkdir()
  164. (cache_dir / "preview.thr").write_text("should be skipped")
  165. (patterns_dir / "real_pattern.thr").write_text("0 0.5")
  166. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  167. from modules.core.pattern_manager import list_theta_rho_files
  168. files = list_theta_rho_files()
  169. # Should only list the real pattern, not cached files
  170. assert len(files) == 1
  171. assert "real_pattern.thr" in files
  172. def test_list_theta_rho_files_empty_directory(self, tmp_path):
  173. """Test listing from empty directory returns empty list."""
  174. patterns_dir = tmp_path / "patterns"
  175. patterns_dir.mkdir()
  176. with patch("modules.core.pattern_manager.THETA_RHO_DIR", str(patterns_dir)):
  177. from modules.core.pattern_manager import list_theta_rho_files
  178. files = list_theta_rho_files()
  179. assert files == []
  180. class TestSdPathResolution:
  181. """_to_sd_path / make_sd_path_resolver: host <-> board SD path mapping."""
  182. def test_to_sd_path_keeps_custom_patterns_dir(self):
  183. from modules.core.pattern_manager import _to_sd_path
  184. # The old find('patterns/') logic matched inside 'custom_patterns/'
  185. # and dropped that directory from the SD path.
  186. assert _to_sd_path("custom_patterns/foo.thr") == "/patterns/custom_patterns/foo.thr"
  187. assert _to_sd_path("./patterns/custom_patterns/foo.thr") == "/patterns/custom_patterns/foo.thr"
  188. assert _to_sd_path("./patterns/star.thr") == "/patterns/star.thr"
  189. assert _to_sd_path("patterns/sub/wave.thr") == "/patterns/sub/wave.thr"
  190. def test_resolver_reuses_existing_board_suffix(self):
  191. from modules.core import pattern_manager
  192. conn = MagicMock()
  193. conn.list_patterns.return_value = [
  194. "sand-patterns/patterns/alligator.thr",
  195. "star.thr",
  196. ]
  197. resolve = pattern_manager.make_sd_path_resolver(conn)
  198. # Host copy nested under custom_patterns/ reuses the board's location.
  199. assert (
  200. resolve("custom_patterns/sand-patterns/patterns/alligator.thr")
  201. == "/patterns/sand-patterns/patterns/alligator.thr"
  202. )
  203. # Exact board match stays canonical.
  204. assert resolve("./patterns/star.thr") == "/patterns/star.thr"
  205. # Not on the board at all: canonical path (will be uploaded there).
  206. assert resolve("custom_patterns/new.thr") == "/patterns/custom_patterns/new.thr"
  207. # The listing is fetched once for the whole resolver lifetime.
  208. conn.list_patterns.assert_called_once()
  209. def test_resolver_unique_basename_match(self):
  210. from modules.core import pattern_manager
  211. conn = MagicMock()
  212. conn.list_patterns.return_value = ["library/animals/heart.thr"]
  213. resolve = pattern_manager.make_sd_path_resolver(conn)
  214. assert resolve("uploads/heart.thr") == "/patterns/library/animals/heart.thr"
  215. def test_resolver_ambiguous_basename_falls_back(self):
  216. from modules.core import pattern_manager
  217. conn = MagicMock()
  218. conn.list_patterns.return_value = ["a/heart.thr", "b/heart.thr"]
  219. resolve = pattern_manager.make_sd_path_resolver(conn)
  220. assert resolve("uploads/heart.thr") == "/patterns/uploads/heart.thr"
  221. def test_resolver_survives_listing_failure(self):
  222. from modules.core import pattern_manager
  223. conn = MagicMock()
  224. conn.list_patterns.side_effect = RuntimeError("board offline")
  225. resolve = pattern_manager.make_sd_path_resolver(conn)
  226. assert resolve("custom_patterns/foo.thr") == "/patterns/custom_patterns/foo.thr"