test_slice_preview.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  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 json
  11. import zipfile
  12. from typing import Any
  13. from unittest.mock import patch
  14. import pytest
  15. from backend.app.services import slice_preview
  16. from backend.app.services.slice_preview import (
  17. _PREVIEW_CACHE_MAX,
  18. _parse_filaments_from_sliced_3mf,
  19. get_preview_filaments,
  20. )
  21. from backend.app.services.slicer_api import (
  22. SlicerApiServerError,
  23. SlicerApiUnavailableError,
  24. SliceResult,
  25. )
  26. def _make_sliced_3mf(plate_id: int, filaments: list[dict[str, str]]) -> bytes:
  27. """Build a fake sliced-3MF zip whose Metadata/slice_info.config has one
  28. plate matching ``plate_id`` with the given filament rows."""
  29. fil_xml = "".join(
  30. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}"'
  31. f' used_g="{f.get("used_g", "0")}" used_m="{f.get("used_m", "0")}"'
  32. f' tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  33. for f in filaments
  34. )
  35. slice_info = (
  36. f'<?xml version="1.0"?><config><plate><metadata key="index" value="{plate_id}"/>{fil_xml}</plate></config>'
  37. )
  38. buf = io.BytesIO()
  39. with zipfile.ZipFile(buf, "w") as zf:
  40. zf.writestr("Metadata/slice_info.config", slice_info)
  41. return buf.getvalue()
  42. @pytest.fixture(autouse=True)
  43. def _reset_cache():
  44. """Each test gets an empty cache + lock dict to keep them independent."""
  45. slice_preview._preview_cache.clear()
  46. slice_preview._preview_locks.clear()
  47. yield
  48. slice_preview._preview_cache.clear()
  49. slice_preview._preview_locks.clear()
  50. class _StubService:
  51. """Mimics SlicerApiService just enough for these tests. Records every
  52. `slice_without_profiles` call so we can assert call counts."""
  53. def __init__(self, response_bytes: bytes | None = None, raise_exc: BaseException | None = None) -> None:
  54. self.response_bytes = response_bytes
  55. self.raise_exc = raise_exc
  56. self.calls: list[dict[str, Any]] = []
  57. async def __aenter__(self):
  58. return self
  59. async def __aexit__(self, *exc):
  60. return False
  61. async def slice_without_profiles(self, **kw):
  62. self.calls.append({"method": "slice_without_profiles", **kw})
  63. if self.raise_exc is not None:
  64. raise self.raise_exc
  65. return SliceResult(
  66. content=self.response_bytes or b"",
  67. print_time_seconds=0,
  68. filament_used_g=0.0,
  69. filament_used_mm=0.0,
  70. )
  71. # ---------------------------------------------------------------------------
  72. # _parse_filaments_from_sliced_3mf — pure-function parsing tests.
  73. # ---------------------------------------------------------------------------
  74. class TestParseFilamentsFromSliced3mf:
  75. def test_happy_path(self):
  76. body = _make_sliced_3mf(
  77. plate_id=22,
  78. filaments=[
  79. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "33.9"},
  80. {"id": "6", "type": "PLA", "color": "#FF0000", "used_g": "37.7"},
  81. ],
  82. )
  83. result = _parse_filaments_from_sliced_3mf(body, 22)
  84. assert result is not None
  85. assert [(f["slot_id"], f["color"]) for f in result] == [(1, "#FFFFFF"), (6, "#FF0000")]
  86. assert result[0]["used_grams"] == 33.9
  87. def test_missing_slice_info_returns_none(self):
  88. empty_zip = io.BytesIO()
  89. with zipfile.ZipFile(empty_zip, "w") as zf:
  90. zf.writestr("placeholder.txt", "x")
  91. assert _parse_filaments_from_sliced_3mf(empty_zip.getvalue(), 1) is None
  92. def test_plate_not_in_slice_info_returns_none(self):
  93. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  94. assert _parse_filaments_from_sliced_3mf(body, plate_id=99) is None
  95. def test_corrupt_zip_returns_none(self):
  96. assert _parse_filaments_from_sliced_3mf(b"not a zip file", 1) is None
  97. # ---------------------------------------------------------------------------
  98. # get_preview_filaments — cache + concurrency behaviour.
  99. # ---------------------------------------------------------------------------
  100. class TestGetPreviewFilaments:
  101. @pytest.mark.asyncio
  102. async def test_happy_path_caches_result(self):
  103. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  104. stub = _StubService(response_bytes=body)
  105. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  106. first = await get_preview_filaments(
  107. kind="archive",
  108. source_id=1,
  109. plate_id=1,
  110. file_bytes=b"abc",
  111. file_name="x.3mf",
  112. api_url="http://sidecar",
  113. )
  114. second = 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. assert first is not None
  123. assert first[0]["slot_id"] == 1
  124. assert second == first
  125. # Cache hit — only one slice was actually run.
  126. assert len(stub.calls) == 1
  127. @pytest.mark.asyncio
  128. async def test_different_content_hash_misses_cache(self):
  129. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  130. stub = _StubService(response_bytes=body)
  131. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  132. await get_preview_filaments(
  133. kind="archive",
  134. source_id=1,
  135. plate_id=1,
  136. file_bytes=b"v1",
  137. file_name="x.3mf",
  138. api_url="http://sidecar",
  139. )
  140. await get_preview_filaments(
  141. kind="archive",
  142. source_id=1,
  143. plate_id=1,
  144. file_bytes=b"v2", # Same archive, but content changed
  145. file_name="x.3mf",
  146. api_url="http://sidecar",
  147. )
  148. # Hash differs → cache miss → fresh slice.
  149. assert len(stub.calls) == 2
  150. @pytest.mark.asyncio
  151. async def test_sidecar_unavailable_returns_none_no_cache(self):
  152. # Transient sidecar failure must NOT poison the cache — the next
  153. # request retries cleanly.
  154. stub = _StubService(raise_exc=SlicerApiUnavailableError("boom"))
  155. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  156. first = await get_preview_filaments(
  157. kind="archive",
  158. source_id=1,
  159. plate_id=1,
  160. file_bytes=b"abc",
  161. file_name="x.3mf",
  162. api_url="http://sidecar",
  163. )
  164. assert first is None
  165. # Second call hits the sidecar again (no cached failure).
  166. await get_preview_filaments(
  167. kind="archive",
  168. source_id=1,
  169. plate_id=1,
  170. file_bytes=b"abc",
  171. file_name="x.3mf",
  172. api_url="http://sidecar",
  173. )
  174. assert len(stub.calls) == 2
  175. @pytest.mark.asyncio
  176. async def test_concurrent_calls_share_one_slice(self):
  177. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  178. # Slow stub so we can observe N coroutines piling up on the lock.
  179. class _SlowStub(_StubService):
  180. async def slice_without_profiles(self, **kw):
  181. self.calls.append(kw)
  182. await asyncio.sleep(0.05)
  183. return SliceResult(
  184. content=self.response_bytes or b"",
  185. print_time_seconds=0,
  186. filament_used_g=0.0,
  187. filament_used_mm=0.0,
  188. )
  189. stub = _SlowStub(response_bytes=body)
  190. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  191. results = await asyncio.gather(
  192. *(
  193. get_preview_filaments(
  194. kind="archive",
  195. source_id=1,
  196. plate_id=1,
  197. file_bytes=b"abc",
  198. file_name="x.3mf",
  199. api_url="http://sidecar",
  200. )
  201. for _ in range(8)
  202. ),
  203. )
  204. # All 8 callers got the same result, but only ONE slice ran.
  205. assert all(r == results[0] for r in results)
  206. assert len(stub.calls) == 1
  207. @pytest.mark.asyncio
  208. async def test_lru_eviction_drops_lock(self):
  209. # Fill cache past the bound; oldest should evict, including its lock.
  210. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  211. stub = _StubService(response_bytes=body)
  212. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  213. # Each call has a unique source_id → unique cache key.
  214. for i in range(_PREVIEW_CACHE_MAX + 5):
  215. await get_preview_filaments(
  216. kind="archive",
  217. source_id=i,
  218. plate_id=1,
  219. file_bytes=b"abc",
  220. file_name="x.3mf",
  221. api_url="http://sidecar",
  222. )
  223. # Cache is bounded — older entries fell off.
  224. assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
  225. # Lock dict is also pruned (no leak): same size as cache.
  226. assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX
  227. # ---------------------------------------------------------------------------
  228. # Unparsable custom G-code — a 3MF from a Studio newer than the sidecar.
  229. #
  230. # Studio 2.8 writes `{if timelapse_inline_photo}` into `time_lapse_gcode`
  231. # without exporting a definition for that variable, so an older sidecar dies
  232. # on a placeholder parse error before any slice_info exists. Blanking just
  233. # that template lets the slice finish on the file's own settings.
  234. # ---------------------------------------------------------------------------
  235. # Reproduced verbatim from a Bambu Studio 2.7.1.62 sidecar refusing an H2D
  236. # project saved by Studio 02.08.00.50. Note the slicer says `timelapse_gcode`
  237. # while the 3MF stores the field as `time_lapse_gcode`.
  238. _TIMELAPSE_PARSE_ERROR = (
  239. "Slicer CLI failed (500): Slicing failed with error from slicer: Failed slicing the model.: "
  240. "Slicer process failed (exit code 156)\n"
  241. "stderr: Failed to generate gcode for invalid custom G-code.\n\n"
  242. "timelapse_gcode Parsing error at line 13: Not a variable name\n"
  243. " {if timelapse_inline_photo}\n"
  244. " ^\n"
  245. )
  246. def _make_project_3mf(settings: dict[str, Any], extra: dict[str, bytes] | None = None) -> bytes:
  247. buf = io.BytesIO()
  248. with zipfile.ZipFile(buf, "w") as zf:
  249. zf.writestr("Metadata/project_settings.config", json.dumps(settings))
  250. for name, data in (extra or {}).items():
  251. zf.writestr(name, data)
  252. return buf.getvalue()
  253. def _settings_of(file_bytes: bytes) -> dict[str, Any]:
  254. with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
  255. return json.loads(zf.read("Metadata/project_settings.config").decode())
  256. class TestUnparsableGcodeOption:
  257. def test_names_the_field_the_slicer_choked_on(self):
  258. assert slice_preview._unparsable_gcode_option(_TIMELAPSE_PARSE_ERROR) == "timelapsegcode"
  259. def test_unrelated_failure_is_not_a_gcode_problem(self):
  260. err = "Slicer CLI failed (500): raft_first_layer_expansion: -1 not in range [0, 340282346638]"
  261. assert slice_preview._unparsable_gcode_option(err) is None
  262. def test_refuses_a_field_that_extrudes(self):
  263. # The whole safety argument for this retry: silencing a template that
  264. # lays a prime line or purges would change the grams the preview
  265. # exists to report. Returning nothing beats returning wrong numbers.
  266. for field in ("machine_start_gcode", "change_filament_gcode", "filament_start_gcode"):
  267. err = f"{field} Parsing error at line 3: Not a variable name\n {{if whatever}}\n"
  268. assert slice_preview._unparsable_gcode_option(err) is None, field
  269. class TestBlankCustomGcode:
  270. def test_blanks_the_matching_field_despite_the_spelling_difference(self):
  271. original = _make_project_3mf(
  272. {
  273. "time_lapse_gcode": "M971 S11 C10\n{if timelapse_inline_photo}\n",
  274. "machine_start_gcode": "G28 ; home",
  275. "filament_colour": ["#FFFFFF", "#000000"],
  276. }
  277. )
  278. out = slice_preview._blank_custom_gcode(original, "timelapsegcode")
  279. assert out is not None
  280. settings = _settings_of(out)
  281. assert settings["time_lapse_gcode"] == ""
  282. # Everything else survives untouched — the preview's accuracy depends
  283. # on the file's own process/support/filament settings being intact.
  284. assert settings["machine_start_gcode"] == "G28 ; home"
  285. assert settings["filament_colour"] == ["#FFFFFF", "#000000"]
  286. def test_keeps_every_other_archive_member(self):
  287. original = _make_project_3mf(
  288. {"time_lapse_gcode": "x"},
  289. extra={"3D/3dmodel.model": b"<model/>", "Metadata/plate_1.png": b"\x89PNG"},
  290. )
  291. out = slice_preview._blank_custom_gcode(original, "timelapsegcode")
  292. assert out is not None
  293. with zipfile.ZipFile(io.BytesIO(out)) as zf:
  294. assert zf.read("3D/3dmodel.model") == b"<model/>"
  295. assert zf.read("Metadata/plate_1.png") == b"\x89PNG"
  296. def test_preserves_a_list_valued_template(self):
  297. original = _make_project_3mf({"layer_change_gcode": ["a", "b", "c"]})
  298. out = slice_preview._blank_custom_gcode(original, "layerchangegcode")
  299. assert out is not None
  300. assert _settings_of(out)["layer_change_gcode"] == ["", "", ""]
  301. def test_no_retry_when_the_field_is_already_empty(self):
  302. # Blanking an empty field would produce a byte-identical request and
  303. # the identical failure, so the caller must be told not to bother.
  304. assert slice_preview._blank_custom_gcode(_make_project_3mf({"time_lapse_gcode": ""}), "timelapsegcode") is None
  305. def test_no_match_no_retry(self):
  306. assert slice_preview._blank_custom_gcode(_make_project_3mf({"other": "x"}), "timelapsegcode") is None
  307. def test_only_gcode_keys_are_eligible(self):
  308. # The normalising fold must not let a same-stem non-template setting
  309. # be silently rewritten.
  310. original = _make_project_3mf({"timelapse_type": "0", "timelapse_gcode_extra": "keep"})
  311. assert slice_preview._blank_custom_gcode(original, "timelapsetype") is None
  312. def test_non_3mf_input_is_not_a_crash(self):
  313. assert slice_preview._blank_custom_gcode(b"not a zip", "timelapsegcode") is None
  314. def test_3mf_without_embedded_settings(self):
  315. buf = io.BytesIO()
  316. with zipfile.ZipFile(buf, "w") as zf:
  317. zf.writestr("3D/3dmodel.model", "<model/>")
  318. assert slice_preview._blank_custom_gcode(buf.getvalue(), "timelapsegcode") is None
  319. class _FailThenSucceedService:
  320. """Fails the first slice with ``first_error``, then succeeds."""
  321. def __init__(self, first_error: BaseException, response_bytes: bytes) -> None:
  322. self.first_error = first_error
  323. self.response_bytes = response_bytes
  324. self.calls: list[bytes] = []
  325. async def __aenter__(self):
  326. return self
  327. async def __aexit__(self, *exc):
  328. return False
  329. async def slice_without_profiles(self, **kw):
  330. self.calls.append(kw["model_bytes"])
  331. if len(self.calls) == 1:
  332. raise self.first_error
  333. return SliceResult(
  334. content=self.response_bytes,
  335. print_time_seconds=0,
  336. filament_used_g=0.0,
  337. filament_used_mm=0.0,
  338. )
  339. class TestPreviewRetriesUnparsableGcode:
  340. @pytest.mark.asyncio
  341. async def test_retries_once_with_the_template_blanked(self):
  342. original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
  343. body = _make_sliced_3mf(
  344. plate_id=1,
  345. filaments=[
  346. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "77.9"},
  347. {"id": "4", "type": "PLA-S", "color": "#0F80FF", "used_g": "11.5"},
  348. ],
  349. )
  350. stub = _FailThenSucceedService(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), body)
  351. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  352. result = await get_preview_filaments(
  353. kind="library_file",
  354. source_id=18000,
  355. plate_id=1,
  356. file_bytes=original,
  357. file_name="x.3mf",
  358. api_url="http://sidecar",
  359. )
  360. assert result is not None
  361. # The support slot must survive — losing it is exactly the failure a
  362. # profile-override fallback would have introduced.
  363. assert [f["slot_id"] for f in result] == [1, 4]
  364. assert len(stub.calls) == 2
  365. # The retry sent a modified file, not the original.
  366. assert stub.calls[1] != original
  367. assert _settings_of(stub.calls[1])["time_lapse_gcode"] == ""
  368. @pytest.mark.asyncio
  369. async def test_result_is_cached_under_the_original_file_hash(self):
  370. original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
  371. body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
  372. stub = _FailThenSucceedService(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), body)
  373. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  374. first = await get_preview_filaments(
  375. kind="library_file",
  376. source_id=1,
  377. plate_id=1,
  378. file_bytes=original,
  379. file_name="x.3mf",
  380. api_url="http://sidecar",
  381. )
  382. second = await get_preview_filaments(
  383. kind="library_file",
  384. source_id=1,
  385. plate_id=1,
  386. file_bytes=original,
  387. file_name="x.3mf",
  388. api_url="http://sidecar",
  389. )
  390. assert second == first
  391. # Two slices for the first call, none for the second.
  392. assert len(stub.calls) == 2
  393. @pytest.mark.asyncio
  394. async def test_no_retry_for_an_unrelated_failure(self):
  395. original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
  396. stub = _FailThenSucceedService(
  397. SlicerApiUnavailableError("Slicer sidecar unreachable"),
  398. _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}]),
  399. )
  400. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  401. result = await get_preview_filaments(
  402. kind="library_file",
  403. source_id=1,
  404. plate_id=1,
  405. file_bytes=original,
  406. file_name="x.3mf",
  407. api_url="http://sidecar",
  408. )
  409. assert result is None
  410. assert len(stub.calls) == 1
  411. @pytest.mark.asyncio
  412. async def test_a_failing_retry_falls_through_rather_than_raising(self):
  413. original = _make_project_3mf({"time_lapse_gcode": "{if timelapse_inline_photo}"})
  414. class _AlwaysFails(_FailThenSucceedService):
  415. async def slice_without_profiles(self, **kw):
  416. self.calls.append(kw["model_bytes"])
  417. raise self.first_error
  418. stub = _AlwaysFails(SlicerApiServerError(_TIMELAPSE_PARSE_ERROR), b"")
  419. with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
  420. result = await get_preview_filaments(
  421. kind="library_file",
  422. source_id=1,
  423. plate_id=1,
  424. file_bytes=original,
  425. file_name="x.3mf",
  426. api_url="http://sidecar",
  427. )
  428. assert result is None
  429. assert len(stub.calls) == 2
  430. # A failed retry must not be cached — the sidecar may be upgraded.
  431. assert not slice_preview._preview_cache