test_stl_thumbnail.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """Unit tests for the STL thumbnail service."""
  2. import os
  3. import tempfile
  4. from pathlib import Path
  5. import pytest
  6. def _check_trimesh_available():
  7. """Check if trimesh is available for import."""
  8. try:
  9. import trimesh
  10. return True
  11. except ImportError:
  12. return False
  13. class TestStlThumbnailService:
  14. """Tests for STL thumbnail generation service."""
  15. def test_generate_stl_thumbnail_imports_available(self):
  16. """Test that required imports are available."""
  17. try:
  18. import matplotlib
  19. import trimesh
  20. assert trimesh is not None
  21. assert matplotlib is not None
  22. except ImportError as e:
  23. pytest.skip(f"Required dependencies not installed: {e}")
  24. def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):
  25. """Test graceful degradation when dependencies are missing."""
  26. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  27. with tempfile.TemporaryDirectory() as tmpdir:
  28. stl_path = Path(tmpdir) / "test.stl"
  29. thumbnails_dir = Path(tmpdir)
  30. # Create a dummy STL file (will fail to parse)
  31. stl_path.write_text("invalid stl content")
  32. # Should return None on failure, not raise
  33. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  34. assert result is None
  35. @pytest.mark.skipif(
  36. not _check_trimesh_available(),
  37. reason="trimesh not installed",
  38. )
  39. def test_generate_stl_thumbnail_with_simple_cube(self):
  40. """Test thumbnail generation with a simple cube STL."""
  41. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  42. with tempfile.TemporaryDirectory() as tmpdir:
  43. stl_path = Path(tmpdir) / "cube.stl"
  44. thumbnails_dir = Path(tmpdir)
  45. # Create a simple ASCII STL cube
  46. stl_content = """solid cube
  47. facet normal 0 0 -1
  48. outer loop
  49. vertex 0 0 0
  50. vertex 1 0 0
  51. vertex 1 1 0
  52. endloop
  53. endfacet
  54. facet normal 0 0 -1
  55. outer loop
  56. vertex 0 0 0
  57. vertex 1 1 0
  58. vertex 0 1 0
  59. endloop
  60. endfacet
  61. facet normal 0 0 1
  62. outer loop
  63. vertex 0 0 1
  64. vertex 1 1 1
  65. vertex 1 0 1
  66. endloop
  67. endfacet
  68. facet normal 0 0 1
  69. outer loop
  70. vertex 0 0 1
  71. vertex 0 1 1
  72. vertex 1 1 1
  73. endloop
  74. endfacet
  75. facet normal 0 -1 0
  76. outer loop
  77. vertex 0 0 0
  78. vertex 1 0 1
  79. vertex 1 0 0
  80. endloop
  81. endfacet
  82. facet normal 0 -1 0
  83. outer loop
  84. vertex 0 0 0
  85. vertex 0 0 1
  86. vertex 1 0 1
  87. endloop
  88. endfacet
  89. facet normal 1 0 0
  90. outer loop
  91. vertex 1 0 0
  92. vertex 1 0 1
  93. vertex 1 1 1
  94. endloop
  95. endfacet
  96. facet normal 1 0 0
  97. outer loop
  98. vertex 1 0 0
  99. vertex 1 1 1
  100. vertex 1 1 0
  101. endloop
  102. endfacet
  103. facet normal 0 1 0
  104. outer loop
  105. vertex 0 1 0
  106. vertex 1 1 0
  107. vertex 1 1 1
  108. endloop
  109. endfacet
  110. facet normal 0 1 0
  111. outer loop
  112. vertex 0 1 0
  113. vertex 1 1 1
  114. vertex 0 1 1
  115. endloop
  116. endfacet
  117. facet normal -1 0 0
  118. outer loop
  119. vertex 0 0 0
  120. vertex 0 1 0
  121. vertex 0 1 1
  122. endloop
  123. endfacet
  124. facet normal -1 0 0
  125. outer loop
  126. vertex 0 0 0
  127. vertex 0 1 1
  128. vertex 0 0 1
  129. endloop
  130. endfacet
  131. endsolid cube"""
  132. stl_path.write_text(stl_content)
  133. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  134. # Should return a path to the generated thumbnail
  135. if result:
  136. assert Path(result).exists()
  137. assert Path(result).suffix == ".png"
  138. # If result is None, dependencies might not be fully functional
  139. # which is acceptable
  140. def test_generate_stl_thumbnail_nonexistent_file(self):
  141. """Test thumbnail generation with nonexistent file."""
  142. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  143. with tempfile.TemporaryDirectory() as tmpdir:
  144. stl_path = Path(tmpdir) / "nonexistent.stl"
  145. thumbnails_dir = Path(tmpdir)
  146. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  147. assert result is None
  148. def test_generate_stl_thumbnail_empty_file(self):
  149. """Test thumbnail generation with empty file."""
  150. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  151. with tempfile.TemporaryDirectory() as tmpdir:
  152. stl_path = Path(tmpdir) / "empty.stl"
  153. thumbnails_dir = Path(tmpdir)
  154. # Create empty file
  155. stl_path.write_bytes(b"")
  156. result = generate_stl_thumbnail(stl_path, thumbnails_dir)
  157. assert result is None
  158. @pytest.mark.skipif(
  159. not _check_trimesh_available(),
  160. reason="trimesh not installed",
  161. )
  162. def test_string_arguments_accepted_without_typeerror(self):
  163. """Regression for #1299: external-scan path passed both args as str.
  164. Before the fix, the function did ``thumbnails_dir / thumb_filename`` on
  165. a ``str`` and raised ``TypeError: unsupported operand type(s) for /:
  166. 'str' and 'str'`` for every STL on an external folder scan. The fix
  167. coerces both args to ``Path`` at entry. This test passes string args
  168. and asserts the function either succeeds or returns ``None`` — but
  169. never raises the TypeError.
  170. """
  171. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  172. with tempfile.TemporaryDirectory() as tmpdir:
  173. stl_path = Path(tmpdir) / "cube.stl"
  174. # Minimal valid binary STL: header (80 bytes) + tri count (0)
  175. stl_path.write_bytes(b"\x00" * 80 + (0).to_bytes(4, "little"))
  176. # str args — the exact shape the external-scan call site used.
  177. result = generate_stl_thumbnail(str(stl_path), str(tmpdir))
  178. # Zero-triangle mesh either yields no thumbnail or fails the
  179. # downstream render — both are acceptable; what's NOT acceptable
  180. # is a TypeError leaking out, which is what the str/str bug did.
  181. assert result is None or Path(result).exists()
  182. class TestStlThumbnailConstants:
  183. """Tests for STL thumbnail service constants."""
  184. def test_bambu_green_color(self):
  185. """Test that Bambu green color is defined."""
  186. from backend.app.services.stl_thumbnail import BAMBU_GREEN
  187. assert BAMBU_GREEN == "#00AE42"
  188. def test_background_color(self):
  189. """Test that background color is defined."""
  190. from backend.app.services.stl_thumbnail import BACKGROUND_COLOR
  191. assert BACKGROUND_COLOR == "#1a1a1a"
  192. def test_max_vertices_threshold(self):
  193. """Test that max vertices threshold is defined."""
  194. from backend.app.services.stl_thumbnail import MAX_VERTICES
  195. assert MAX_VERTICES == 100000
  196. def test_min_usable_stl_bytes_threshold(self):
  197. """MIN_USABLE_STL_BYTES is the call-site pre-skip floor.
  198. Binary STL with one triangle = 80B header + 4B count + 50B triangle
  199. = 134B. ASCII STL with one triangle ≈ 150B. Anything below this size
  200. cannot contain a usable mesh.
  201. """
  202. from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES
  203. assert MIN_USABLE_STL_BYTES == 200
  204. # Verify it sits between "smaller than smallest real STL" and
  205. # "common stub size" — the 24-byte ``solid test\nendsolid test``
  206. # stubs that triggered the warning storm.
  207. assert MIN_USABLE_STL_BYTES > 134 # smallest binary STL with one triangle
  208. assert MIN_USABLE_STL_BYTES > 150 # smallest ASCII STL with one triangle
  209. assert MIN_USABLE_STL_BYTES > 24 # the ZIP-stub case in the bug report
  210. def test_font_manager_logger_demoted_to_warning(self):
  211. """matplotlib.font_manager's per-font INFO scan is demoted at module
  212. import so the first STL upload doesn't surface a multi-line preamble
  213. of matplotlib internals in the journal."""
  214. import logging
  215. # Importing the module sets the level as a side effect.
  216. import backend.app.services.stl_thumbnail # noqa: F401
  217. assert logging.getLogger("matplotlib.font_manager").level >= logging.WARNING
  218. def test_configure_matplotlib_cache_sets_mplconfigdir(self, tmp_path, monkeypatch):
  219. """``_configure_matplotlib_cache`` points matplotlib at a writable
  220. persistent path so it doesn't fall back to ``/tmp/matplotlib-XXX``
  221. on every cold start."""
  222. from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
  223. # Ensure we start with no value so the helper actually runs.
  224. monkeypatch.delenv("MPLCONFIGDIR", raising=False)
  225. monkeypatch.setattr(
  226. "backend.app.services.stl_thumbnail.Path",
  227. __import__("pathlib").Path,
  228. )
  229. # Stub settings.base_dir to point inside tmp_path.
  230. from backend.app.core import config as core_config
  231. monkeypatch.setattr(core_config.settings, "base_dir", tmp_path, raising=False)
  232. _configure_matplotlib_cache()
  233. assert "MPLCONFIGDIR" in os.environ
  234. configured = Path(os.environ["MPLCONFIGDIR"])
  235. assert configured.exists()
  236. assert configured.is_dir()
  237. # And the directory sits under base_dir, not /tmp/matplotlib-XXX.
  238. assert tmp_path in configured.parents
  239. def test_configure_matplotlib_cache_respects_externally_set_value(self, tmp_path, monkeypatch):
  240. """If the operator (or container init) has set MPLCONFIGDIR already,
  241. the helper must leave it alone — they made a deliberate choice."""
  242. from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
  243. external = str(tmp_path / "external-mpl-cache")
  244. monkeypatch.setenv("MPLCONFIGDIR", external)
  245. _configure_matplotlib_cache()
  246. assert os.environ["MPLCONFIGDIR"] == external
  247. def test_empty_mesh_logged_at_debug_not_warning(self, caplog):
  248. """An empty STL (header present, no triangles) must log at DEBUG, not
  249. WARNING — bulk uploads used to log thousands of WARNING lines per
  250. ZIP. Per-file content observations stay observable in debug logs
  251. but don't spam production journals."""
  252. import logging
  253. import tempfile
  254. from pathlib import Path
  255. from backend.app.services.stl_thumbnail import generate_stl_thumbnail
  256. # The exact 24-byte stub from the bug report
  257. stub_content = b"solid test\nendsolid test"
  258. with tempfile.TemporaryDirectory() as tmpdir:
  259. tmpdir_path = Path(tmpdir)
  260. stl_path = tmpdir_path / "stub.stl"
  261. stl_path.write_bytes(stub_content)
  262. with caplog.at_level(logging.DEBUG, logger="backend.app.services.stl_thumbnail"):
  263. result = generate_stl_thumbnail(stl_path, tmpdir_path)
  264. assert result is None
  265. # The empty-mesh message must NOT appear at WARNING level.
  266. warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING and "empty mesh" in r.getMessage()]
  267. assert warning_records == [], (
  268. f"Empty-mesh path still logs at WARNING: {[r.getMessage() for r in warning_records]}"
  269. )