test_slice_preview.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. """Unit tests for the preview-slice cache.
  2. The preview-slice runs the sidecar's `slice_without_profiles` on an unsliced
  3. project file to extract the per-plate filament list. Results are cached by
  4. ``(kind, source_id, plate_id, content_hash)`` with LRU eviction so repeat
  5. modal opens on the same plate are instant.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import io
  10. import zipfile
  11. from typing import Any
  12. from unittest.mock import patch
  13. import pytest
  14. from backend.app.services import slice_preview
  15. from backend.app.services.slice_preview import (
  16. _PREVIEW_CACHE_MAX,
  17. _parse_filaments_from_sliced_3mf,
  18. get_preview_filaments,
  19. )
  20. from backend.app.services.slicer_api import (
  21. SlicerApiUnavailableError,
  22. SliceResult,
  23. )
  24. def _make_sliced_3mf(plate_id: int, filaments: list[dict[str, str]]) -> bytes:
  25. """Build a fake sliced-3MF zip whose Metadata/slice_info.config has one
  26. plate matching ``plate_id`` with the given filament rows."""
  27. fil_xml = "".join(
  28. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}"'
  29. f' used_g="{f.get("used_g", "0")}" used_m="{f.get("used_m", "0")}"'
  30. f' tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  31. for f in filaments
  32. )
  33. slice_info = (
  34. f'<?xml version="1.0"?><config><plate><metadata key="index" value="{plate_id}"/>{fil_xml}</plate></config>'
  35. )
  36. buf = io.BytesIO()
  37. with zipfile.ZipFile(buf, "w") as zf:
  38. zf.writestr("Metadata/slice_info.config", slice_info)
  39. return buf.getvalue()
  40. @pytest.fixture(autouse=True)
  41. def _reset_cache():
  42. """Each test gets an empty cache + lock dict to keep them independent."""
  43. slice_preview._preview_cache.clear()
  44. slice_preview._preview_locks.clear()
  45. yield
  46. slice_preview._preview_cache.clear()
  47. slice_preview._preview_locks.clear()
  48. class _StubService:
  49. """Mimics SlicerApiService just enough for these tests. Records every
  50. `slice_without_profiles` call so we can assert call counts."""
  51. def __init__(self, response_bytes: bytes | None = None, raise_exc: BaseException | None = None) -> None:
  52. self.response_bytes = response_bytes
  53. self.raise_exc = raise_exc
  54. self.calls: list[dict[str, Any]] = []
  55. async def __aenter__(self):
  56. return self
  57. async def __aexit__(self, *exc):
  58. return False
  59. async def slice_without_profiles(self, **kw):
  60. self.calls.append({"method": "slice_without_profiles", **kw})
  61. if self.raise_exc is not None:
  62. raise self.raise_exc
  63. return SliceResult(
  64. content=self.response_bytes or b"",
  65. print_time_seconds=0,
  66. filament_used_g=0.0,
  67. filament_used_mm=0.0,
  68. )
  69. async def slice_with_bundle(self, **kw):
  70. self.calls.append({"method": "slice_with_bundle", **kw})
  71. if self.raise_exc is not None:
  72. raise self.raise_exc
  73. return SliceResult(
  74. content=self.response_bytes or b"",
  75. print_time_seconds=0,
  76. filament_used_g=0.0,
  77. filament_used_mm=0.0,
  78. )
  79. # ---------------------------------------------------------------------------
  80. # _parse_filaments_from_sliced_3mf — pure-function parsing tests.
  81. # ---------------------------------------------------------------------------
  82. class TestParseFilamentsFromSliced3mf:
  83. def test_happy_path(self):
  84. body = _make_sliced_3mf(
  85. plate_id=22,
  86. filaments=[
  87. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "33.9"},
  88. {"id": "6", "type": "PLA", "color": "#FF0000", "used_g": "37.7"},
  89. ],
  90. )
  91. result = _parse_filaments_from_sliced_3mf(body, 22)
  92. assert result is not None
  93. assert [(f["slot_id"], f["color"]) for f in result] == [(1, "#FFFFFF"), (6, "#FF0000")]
  94. assert result[0]["used_grams"] == 33.9
  95. def test_missing_slice_info_returns_none(self):
  96. empty_zip = io.BytesIO()
  97. with zipfile.ZipFile(empty_zip, "w") as zf:
  98. zf.writestr("placeholder.txt", "x")
  99. assert _parse_filaments_from_sliced_3mf(empty_zip.getvalue(), 1) is None
  100. def test_plate_not_in_slice_info_returns_none(self):
  101. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  102. assert _parse_filaments_from_sliced_3mf(body, plate_id=99) is None
  103. def test_corrupt_zip_returns_none(self):
  104. assert _parse_filaments_from_sliced_3mf(b"not a zip file", 1) is None
  105. # ---------------------------------------------------------------------------
  106. # get_preview_filaments — cache + concurrency behaviour.
  107. # ---------------------------------------------------------------------------
  108. class TestGetPreviewFilaments:
  109. @pytest.mark.asyncio
  110. async def test_happy_path_caches_result(self):
  111. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  112. stub = _StubService(response_bytes=body)
  113. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  114. first = await get_preview_filaments(
  115. kind="archive",
  116. source_id=1,
  117. plate_id=1,
  118. file_bytes=b"abc",
  119. file_name="x.3mf",
  120. api_url="http://sidecar",
  121. )
  122. second = await get_preview_filaments(
  123. kind="archive",
  124. source_id=1,
  125. plate_id=1,
  126. file_bytes=b"abc",
  127. file_name="x.3mf",
  128. api_url="http://sidecar",
  129. )
  130. assert first is not None
  131. assert first[0]["slot_id"] == 1
  132. assert second == first
  133. # Cache hit — only one slice was actually run.
  134. assert len(stub.calls) == 1
  135. @pytest.mark.asyncio
  136. async def test_different_content_hash_misses_cache(self):
  137. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  138. stub = _StubService(response_bytes=body)
  139. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  140. await get_preview_filaments(
  141. kind="archive",
  142. source_id=1,
  143. plate_id=1,
  144. file_bytes=b"v1",
  145. file_name="x.3mf",
  146. api_url="http://sidecar",
  147. )
  148. await get_preview_filaments(
  149. kind="archive",
  150. source_id=1,
  151. plate_id=1,
  152. file_bytes=b"v2", # Same archive, but content changed
  153. file_name="x.3mf",
  154. api_url="http://sidecar",
  155. )
  156. # Hash differs → cache miss → fresh slice.
  157. assert len(stub.calls) == 2
  158. @pytest.mark.asyncio
  159. async def test_sidecar_unavailable_returns_none_no_cache(self):
  160. # Transient sidecar failure must NOT poison the cache — the next
  161. # request retries cleanly.
  162. stub = _StubService(raise_exc=SlicerApiUnavailableError("boom"))
  163. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  164. first = await get_preview_filaments(
  165. kind="archive",
  166. source_id=1,
  167. plate_id=1,
  168. file_bytes=b"abc",
  169. file_name="x.3mf",
  170. api_url="http://sidecar",
  171. )
  172. assert first is None
  173. # Second call hits the sidecar again (no cached failure).
  174. await get_preview_filaments(
  175. kind="archive",
  176. source_id=1,
  177. plate_id=1,
  178. file_bytes=b"abc",
  179. file_name="x.3mf",
  180. api_url="http://sidecar",
  181. )
  182. assert len(stub.calls) == 2
  183. @pytest.mark.asyncio
  184. async def test_concurrent_calls_share_one_slice(self):
  185. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  186. # Slow stub so we can observe N coroutines piling up on the lock.
  187. class _SlowStub(_StubService):
  188. async def slice_without_profiles(self, **kw):
  189. self.calls.append(kw)
  190. await asyncio.sleep(0.05)
  191. return SliceResult(
  192. content=self.response_bytes or b"",
  193. print_time_seconds=0,
  194. filament_used_g=0.0,
  195. filament_used_mm=0.0,
  196. )
  197. stub = _SlowStub(response_bytes=body)
  198. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  199. results = await asyncio.gather(
  200. *(
  201. get_preview_filaments(
  202. kind="archive",
  203. source_id=1,
  204. plate_id=1,
  205. file_bytes=b"abc",
  206. file_name="x.3mf",
  207. api_url="http://sidecar",
  208. )
  209. for _ in range(8)
  210. ),
  211. )
  212. # All 8 callers got the same result, but only ONE slice ran.
  213. assert all(r == results[0] for r in results)
  214. assert len(stub.calls) == 1
  215. @pytest.mark.asyncio
  216. async def test_lru_eviction_drops_lock(self):
  217. # Fill cache past the bound; oldest should evict, including its lock.
  218. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  219. stub = _StubService(response_bytes=body)
  220. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  221. # Each call has a unique source_id → unique cache key.
  222. for i in range(_PREVIEW_CACHE_MAX + 5):
  223. await get_preview_filaments(
  224. kind="archive",
  225. source_id=i,
  226. plate_id=1,
  227. file_bytes=b"abc",
  228. file_name="x.3mf",
  229. api_url="http://sidecar",
  230. )
  231. # Cache is bounded — older entries fell off.
  232. assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
  233. # Lock dict is also pruned (no leak): same size as cache.
  234. assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX
  235. # ---------------------------------------------------------------------------
  236. # Bundle-aware preview path — when bundle context is supplied, the preview
  237. # routes through `slice_with_bundle` so its gram numbers reflect the same
  238. # triplet the real print will use. Cache must distinguish between bundle
  239. # picks so a fresh selection doesn't re-serve a prior preview's output.
  240. # ---------------------------------------------------------------------------
  241. class TestBundleAwarePreview:
  242. @pytest.mark.asyncio
  243. async def test_full_bundle_context_uses_slice_with_bundle(self):
  244. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  245. stub = _StubService(response_bytes=body)
  246. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  247. result = await get_preview_filaments(
  248. kind="library_file",
  249. source_id=42,
  250. plate_id=1,
  251. file_bytes=b"abc",
  252. file_name="x.3mf",
  253. api_url="http://sidecar",
  254. bundle_id="abc123",
  255. printer_name="# Bambu Lab H2D 0.4 nozzle",
  256. process_name="# 0.20mm Standard @BBL H2D",
  257. filament_names=["# Bambu PLA Basic @BBL H2D"],
  258. )
  259. assert result is not None
  260. assert result[0]["slot_id"] == 1
  261. # The bundle path engaged — slice_with_bundle was called, not the
  262. # embedded-settings fallback.
  263. assert len(stub.calls) == 1
  264. assert stub.calls[0]["method"] == "slice_with_bundle"
  265. assert stub.calls[0]["bundle_id"] == "abc123"
  266. assert stub.calls[0]["filament_names"] == ["# Bambu PLA Basic @BBL H2D"]
  267. @pytest.mark.asyncio
  268. async def test_partial_bundle_context_falls_back_to_embedded(self):
  269. # Modal-in-progress case: user picked a bundle id but hasn't yet
  270. # picked the filament. Falling back to embedded settings keeps
  271. # the preview's slot mapping fresh while gram numbers will firm
  272. # up once the selection completes.
  273. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  274. stub = _StubService(response_bytes=body)
  275. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  276. await get_preview_filaments(
  277. kind="library_file",
  278. source_id=42,
  279. plate_id=1,
  280. file_bytes=b"abc",
  281. file_name="x.3mf",
  282. api_url="http://sidecar",
  283. bundle_id="abc123",
  284. printer_name="# Bambu Lab H2D 0.4 nozzle",
  285. process_name="# 0.20mm Standard @BBL H2D",
  286. # filament_names missing
  287. )
  288. assert len(stub.calls) == 1
  289. assert stub.calls[0]["method"] == "slice_without_profiles"
  290. @pytest.mark.asyncio
  291. async def test_empty_filament_names_list_falls_back(self):
  292. # Empty list (vs None) is treated as "incomplete context" since
  293. # passing `[]` to slice_with_bundle would yield no
  294. # --load-filaments arg and confuse the CLI.
  295. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  296. stub = _StubService(response_bytes=body)
  297. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  298. await get_preview_filaments(
  299. kind="library_file",
  300. source_id=42,
  301. plate_id=1,
  302. file_bytes=b"abc",
  303. file_name="x.3mf",
  304. api_url="http://sidecar",
  305. bundle_id="abc123",
  306. printer_name="P",
  307. process_name="Q",
  308. filament_names=[],
  309. )
  310. assert stub.calls[0]["method"] == "slice_without_profiles"
  311. @pytest.mark.asyncio
  312. async def test_cache_separates_bundle_picks(self):
  313. # Same file/plate, two different bundle picks → two distinct cache
  314. # entries → two slices run. Without the bundle-fingerprint cache key,
  315. # the second call would erroneously serve the first's output.
  316. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  317. stub = _StubService(response_bytes=body)
  318. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  319. await get_preview_filaments(
  320. kind="library_file",
  321. source_id=42,
  322. plate_id=1,
  323. file_bytes=b"abc",
  324. file_name="x.3mf",
  325. api_url="http://sidecar",
  326. bundle_id="bundleA",
  327. printer_name="P",
  328. process_name="Q",
  329. filament_names=["F"],
  330. )
  331. await get_preview_filaments(
  332. kind="library_file",
  333. source_id=42,
  334. plate_id=1,
  335. file_bytes=b"abc",
  336. file_name="x.3mf",
  337. api_url="http://sidecar",
  338. bundle_id="bundleB",
  339. printer_name="P",
  340. process_name="Q",
  341. filament_names=["F"],
  342. )
  343. assert len(stub.calls) == 2
  344. assert stub.calls[0]["bundle_id"] == "bundleA"
  345. assert stub.calls[1]["bundle_id"] == "bundleB"
  346. @pytest.mark.asyncio
  347. async def test_cache_separates_bundle_vs_embedded(self):
  348. # Same file/plate, one call without bundle and one with bundle →
  349. # both must run. The embedded-settings cache entry must NOT be
  350. # served as the bundle-picked result (gram numbers would be wrong).
  351. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  352. stub = _StubService(response_bytes=body)
  353. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  354. await get_preview_filaments(
  355. kind="library_file",
  356. source_id=42,
  357. plate_id=1,
  358. file_bytes=b"abc",
  359. file_name="x.3mf",
  360. api_url="http://sidecar",
  361. )
  362. await get_preview_filaments(
  363. kind="library_file",
  364. source_id=42,
  365. plate_id=1,
  366. file_bytes=b"abc",
  367. file_name="x.3mf",
  368. api_url="http://sidecar",
  369. bundle_id="bundleA",
  370. printer_name="P",
  371. process_name="Q",
  372. filament_names=["F"],
  373. )
  374. methods = [c["method"] for c in stub.calls]
  375. assert methods == ["slice_without_profiles", "slice_with_bundle"]
  376. @pytest.mark.asyncio
  377. async def test_bundle_repeat_call_hits_cache(self):
  378. # Sanity check that the new cache key is otherwise stable: same
  379. # bundle pick on the same file → cache hit on second call.
  380. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  381. stub = _StubService(response_bytes=body)
  382. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  383. for _ in range(2):
  384. await get_preview_filaments(
  385. kind="library_file",
  386. source_id=42,
  387. plate_id=1,
  388. file_bytes=b"abc",
  389. file_name="x.3mf",
  390. api_url="http://sidecar",
  391. bundle_id="bundleA",
  392. printer_name="P",
  393. process_name="Q",
  394. filament_names=["F"],
  395. )
  396. assert len(stub.calls) == 1