test_library_slice_api.py 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523
  1. """Integration tests for the slice-via-API flow.
  2. Routes under test:
  3. - POST /library/files/{id}/slice (returns 202 + job_id; bg task does the work)
  4. - POST /archives/{id}/slice (same shape; result lands in archives table)
  5. - GET /slice-jobs/{id} (poll for terminal state)
  6. The synchronous validation paths (404 missing source, 400 wrong file type)
  7. are tested directly. The bg-task paths poll until the job finishes and then
  8. assert on the captured state.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import io
  13. import json
  14. import zipfile
  15. from collections.abc import Callable
  16. import httpx
  17. import pytest
  18. from httpx import AsyncClient
  19. from backend.app.api.routes.library import _slicer_rejection_message
  20. from backend.app.core.config import settings as app_settings
  21. from backend.app.models.library import LibraryFile
  22. from backend.app.models.local_preset import LocalPreset
  23. from backend.app.models.settings import Settings as SettingsModel
  24. from backend.app.services import slicer_api as slicer_api_module
  25. from backend.app.services.slice_dispatch import slice_dispatch
  26. # ---------------------------------------------------------------------------
  27. # Helpers
  28. # ---------------------------------------------------------------------------
  29. def _make_3mf_with_settings(settings_payload: dict | None = None) -> bytes:
  30. """Build a tiny in-memory 3MF zip with all the embedded-config files
  31. that real-world Bambu Studio / OrcaSlicer 3MFs ship with.
  32. The strip-before-forwarding helper has to remove ALL of these (not
  33. just `project_settings.config`) — leftover entries reference printer
  34. / filament IDs from the original slice and trip the CLI's input
  35. validation when a different `--load-settings` triplet is supplied.
  36. """
  37. buf = io.BytesIO()
  38. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  39. zf.writestr("3D/3dmodel.model", "<model/>")
  40. zf.writestr(
  41. "Metadata/project_settings.config",
  42. json.dumps(settings_payload or {"prime_tower_brim_width": "-1"}),
  43. )
  44. zf.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
  45. zf.writestr(
  46. "Metadata/slice_info.config",
  47. "<config><plate><metadata key='filament' value='GFL00'/></plate></config>",
  48. )
  49. zf.writestr("Metadata/cut_information.xml", "<cut><part id='1'/></cut>")
  50. return buf.getvalue()
  51. def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient:
  52. """Pin a MockTransport-backed httpx client onto the slicer_api singleton
  53. so per-request `SlicerApiService` instances reuse it instead of opening
  54. a real connection."""
  55. client = httpx.AsyncClient(transport=httpx.MockTransport(handler), timeout=10.0)
  56. slicer_api_module.set_shared_http_client(client)
  57. return client
  58. async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
  59. """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
  60. The dispatcher runs work as an asyncio task on the same event loop, so
  61. poll-with-sleep here is enough — a few yields and the task finishes.
  62. """
  63. deadline = asyncio.get_event_loop().time() + timeout
  64. while asyncio.get_event_loop().time() < deadline:
  65. r = await client.get(f"/api/v1/slice-jobs/{job_id}")
  66. if r.status_code != 200:
  67. raise AssertionError(f"slice-jobs poll failed: {r.status_code} {r.text}")
  68. body = r.json()
  69. if body["status"] in ("completed", "failed"):
  70. return body
  71. await asyncio.sleep(0.05)
  72. raise AssertionError(f"slice job {job_id} did not finish in {timeout}s")
  73. # ---------------------------------------------------------------------------
  74. # Fixtures
  75. # ---------------------------------------------------------------------------
  76. @pytest.fixture
  77. async def slice_test_setup(db_session, tmp_path):
  78. """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
  79. storage_dir = tmp_path / "library" / "files"
  80. storage_dir.mkdir(parents=True, exist_ok=True)
  81. src_path = storage_dir / "Cube.stl"
  82. src_path.write_bytes(b"solid Cube\nendsolid\n")
  83. original_base_dir = app_settings.base_dir
  84. app_settings.base_dir = tmp_path
  85. src_file = LibraryFile(
  86. filename="Cube.stl",
  87. file_path=str(src_path.relative_to(tmp_path)),
  88. file_type="stl",
  89. file_size=src_path.stat().st_size,
  90. )
  91. db_session.add(src_file)
  92. presets = {}
  93. for kind in ("printer", "process", "filament"):
  94. p = LocalPreset(
  95. name=f"Test {kind}",
  96. preset_type=kind,
  97. source="orcaslicer",
  98. setting=json.dumps({"name": f"Test {kind}", "type": kind}),
  99. )
  100. db_session.add(p)
  101. presets[kind] = p
  102. db_session.add(SettingsModel(key="preferred_slicer", value="orcaslicer"))
  103. await db_session.commit()
  104. for p in presets.values():
  105. await db_session.refresh(p)
  106. await db_session.refresh(src_file)
  107. yield {
  108. "src_file_id": src_file.id,
  109. "printer_id": presets["printer"].id,
  110. "process_id": presets["process"].id,
  111. "filament_id": presets["filament"].id,
  112. "tmp_path": tmp_path,
  113. }
  114. app_settings.base_dir = original_base_dir
  115. slicer_api_module.set_shared_http_client(None)
  116. # ---------------------------------------------------------------------------
  117. # POST /library/files/{id}/slice — synchronous validation paths
  118. # ---------------------------------------------------------------------------
  119. class TestSliceValidation:
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. async def test_returns_404_when_source_missing(self, async_client: AsyncClient, slice_test_setup):
  123. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  124. response = await async_client.post(
  125. "/api/v1/library/files/999999/slice",
  126. json={
  127. "printer_preset_id": slice_test_setup["printer_id"],
  128. "process_preset_id": slice_test_setup["process_id"],
  129. "filament_preset_id": slice_test_setup["filament_id"],
  130. },
  131. )
  132. assert response.status_code == 404
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_returns_400_for_wrong_file_type(self, async_client: AsyncClient, db_session, slice_test_setup):
  136. gcode_path = slice_test_setup["tmp_path"] / "library" / "files" / "out.gcode"
  137. gcode_path.write_bytes(b"; gcode\n")
  138. gfile = LibraryFile(
  139. filename="out.gcode",
  140. file_path=str(gcode_path.relative_to(slice_test_setup["tmp_path"])),
  141. file_type="gcode",
  142. file_size=10,
  143. )
  144. db_session.add(gfile)
  145. await db_session.commit()
  146. await db_session.refresh(gfile)
  147. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  148. response = await async_client.post(
  149. f"/api/v1/library/files/{gfile.id}/slice",
  150. json={
  151. "printer_preset_id": slice_test_setup["printer_id"],
  152. "process_preset_id": slice_test_setup["process_id"],
  153. "filament_preset_id": slice_test_setup["filament_id"],
  154. },
  155. )
  156. assert response.status_code == 400
  157. assert "STL, 3MF, or STEP" in response.json()["detail"]
  158. # ---------------------------------------------------------------------------
  159. # POST /library/files/{id}/slice — async dispatch + bg job
  160. # ---------------------------------------------------------------------------
  161. class TestSliceLibraryFile:
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_happy_path_returns_202_then_job_completes_with_library_file(
  165. self, async_client: AsyncClient, slice_test_setup
  166. ):
  167. captured: dict = {}
  168. def handler(request: httpx.Request) -> httpx.Response:
  169. captured["url"] = str(request.url)
  170. return httpx.Response(
  171. status_code=200,
  172. content=b"PK\x03\x04 fake-3mf",
  173. headers={
  174. "x-print-time-seconds": "656",
  175. "x-filament-used-g": "0.94",
  176. "x-filament-used-mm": "302.5",
  177. },
  178. )
  179. _install_mock_sidecar(handler)
  180. response = await async_client.post(
  181. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  182. json={
  183. "printer_preset_id": slice_test_setup["printer_id"],
  184. "process_preset_id": slice_test_setup["process_id"],
  185. "filament_preset_id": slice_test_setup["filament_id"],
  186. },
  187. )
  188. assert response.status_code == 202, response.text
  189. body = response.json()
  190. assert body["status"] == "pending"
  191. assert body["status_url"].startswith("/api/v1/slice-jobs/")
  192. final = await _wait_for_job(async_client, body["job_id"])
  193. assert final["status"] == "completed", final
  194. assert final["result"]["library_file_id"] != slice_test_setup["src_file_id"]
  195. assert final["result"]["print_time_seconds"] == 656
  196. assert captured["url"].endswith("/slice")
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_bed_type_override_patches_process_profile(self, async_client: AsyncClient, slice_test_setup):
  200. """#1337: when SliceRequest.bed_type is set, the process JSON sent to
  201. the sidecar must carry curr_bed_type with that exact value. Without
  202. the patch, slicing high-temp filaments on a "Cool Plate" process
  203. preset fails inside the slicer CLI with "does not support filament 1"
  204. and the user has no way to switch plates from the SliceModal."""
  205. captured: dict = {}
  206. def handler(request: httpx.Request) -> httpx.Response:
  207. captured["body"] = bytes(request.content)
  208. return httpx.Response(
  209. status_code=200,
  210. content=b"PK\x03\x04 fake",
  211. headers={
  212. "x-print-time-seconds": "10",
  213. "x-filament-used-g": "0.1",
  214. "x-filament-used-mm": "1.0",
  215. },
  216. )
  217. _install_mock_sidecar(handler)
  218. response = await async_client.post(
  219. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  220. json={
  221. "printer_preset_id": slice_test_setup["printer_id"],
  222. "process_preset_id": slice_test_setup["process_id"],
  223. "filament_preset_id": slice_test_setup["filament_id"],
  224. "bed_type": "Textured PEI Plate",
  225. },
  226. )
  227. assert response.status_code == 202
  228. final = await _wait_for_job(async_client, response.json()["job_id"])
  229. assert final["status"] == "completed", final
  230. # The presetProfile part of the multipart upload now carries the
  231. # override. Searching the raw body avoids parsing the multipart by
  232. # hand — the substring is unique enough since we control the JSON
  233. # being patched.
  234. assert b'"curr_bed_type": "Textured PEI Plate"' in captured["body"], (
  235. "bed_type override must appear in the process JSON sent to the sidecar"
  236. )
  237. @pytest.mark.asyncio
  238. @pytest.mark.integration
  239. async def test_bed_type_omitted_leaves_process_profile_untouched(self, async_client: AsyncClient, slice_test_setup):
  240. """Companion to the override test: the patch must NOT fire when the
  241. client omits bed_type, so the process preset's own curr_bed_type
  242. (or absence thereof) is forwarded to the sidecar unchanged."""
  243. captured: dict = {}
  244. def handler(request: httpx.Request) -> httpx.Response:
  245. captured["body"] = bytes(request.content)
  246. return httpx.Response(
  247. status_code=200,
  248. content=b"PK\x03\x04 fake",
  249. headers={
  250. "x-print-time-seconds": "10",
  251. "x-filament-used-g": "0.1",
  252. "x-filament-used-mm": "1.0",
  253. },
  254. )
  255. _install_mock_sidecar(handler)
  256. response = await async_client.post(
  257. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  258. json={
  259. "printer_preset_id": slice_test_setup["printer_id"],
  260. "process_preset_id": slice_test_setup["process_id"],
  261. "filament_preset_id": slice_test_setup["filament_id"],
  262. },
  263. )
  264. assert response.status_code == 202
  265. final = await _wait_for_job(async_client, response.json()["job_id"])
  266. assert final["status"] == "completed", final
  267. assert b"curr_bed_type" not in captured["body"], (
  268. "bed_type must stay out of the process JSON when no override is set"
  269. )
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
  273. self, async_client: AsyncClient, slice_test_setup
  274. ):
  275. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  276. response = await async_client.post(
  277. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  278. json={
  279. # Swap printer/filament — both exist but wrong preset_type.
  280. "printer_preset_id": slice_test_setup["filament_id"],
  281. "process_preset_id": slice_test_setup["process_id"],
  282. "filament_preset_id": slice_test_setup["printer_id"],
  283. },
  284. )
  285. assert response.status_code == 202
  286. final = await _wait_for_job(async_client, response.json()["job_id"])
  287. assert final["status"] == "failed"
  288. assert final["error_status"] == 400
  289. assert "preset_type" in (final["error_detail"] or "")
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. async def test_unknown_preferred_slicer_fails_with_400(
  293. self, async_client: AsyncClient, db_session, slice_test_setup
  294. ):
  295. await db_session.execute(
  296. SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value="prusaslicer")
  297. )
  298. await db_session.commit()
  299. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  300. response = await async_client.post(
  301. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  302. json={
  303. "printer_preset_id": slice_test_setup["printer_id"],
  304. "process_preset_id": slice_test_setup["process_id"],
  305. "filament_preset_id": slice_test_setup["filament_id"],
  306. },
  307. )
  308. assert response.status_code == 202
  309. final = await _wait_for_job(async_client, response.json()["job_id"])
  310. assert final["status"] == "failed"
  311. assert final["error_status"] == 400
  312. assert "preferred_slicer" in (final["error_detail"] or "")
  313. @pytest.mark.asyncio
  314. @pytest.mark.integration
  315. async def test_sidecar_unreachable_fails_with_502(self, async_client: AsyncClient, slice_test_setup):
  316. def handler(_: httpx.Request) -> httpx.Response:
  317. raise httpx.ConnectError("connection refused")
  318. _install_mock_sidecar(handler)
  319. response = await async_client.post(
  320. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  321. json={
  322. "printer_preset_id": slice_test_setup["printer_id"],
  323. "process_preset_id": slice_test_setup["process_id"],
  324. "filament_preset_id": slice_test_setup["filament_id"],
  325. },
  326. )
  327. assert response.status_code == 202
  328. final = await _wait_for_job(async_client, response.json()["job_id"])
  329. assert final["status"] == "failed"
  330. assert final["error_status"] == 502
  331. assert "unreachable" in (final["error_detail"] or "").lower()
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_3mf_falls_back_to_embedded_settings_on_cli_failure(
  335. self, async_client: AsyncClient, db_session, slice_test_setup
  336. ):
  337. # When the slicer CLI fails on the --load-settings path (segfault
  338. # on complex H2D models), Bambuddy retries with no profile triplet
  339. # so the CLI uses the file's embedded settings.
  340. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex.3mf"
  341. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  342. threemf = LibraryFile(
  343. filename="complex.3mf",
  344. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  345. file_type="3mf",
  346. file_size=src_3mf_path.stat().st_size,
  347. )
  348. db_session.add(threemf)
  349. await db_session.commit()
  350. await db_session.refresh(threemf)
  351. call_count = {"n": 0}
  352. def handler(request: httpx.Request) -> httpx.Response:
  353. call_count["n"] += 1
  354. # First call: profile triplet present → simulate CLI 5xx
  355. if call_count["n"] == 1:
  356. return httpx.Response(
  357. status_code=500,
  358. json={"message": "Failed to slice the model"},
  359. )
  360. # Retry: no profile triplet → succeed with embedded settings
  361. return httpx.Response(
  362. status_code=200,
  363. content=b"PK\x03\x04 fake-3mf",
  364. headers={
  365. "x-print-time-seconds": "100",
  366. "x-filament-used-g": "1.0",
  367. "x-filament-used-mm": "100",
  368. },
  369. )
  370. _install_mock_sidecar(handler)
  371. response = await async_client.post(
  372. f"/api/v1/library/files/{threemf.id}/slice",
  373. json={
  374. "printer_preset_id": slice_test_setup["printer_id"],
  375. "process_preset_id": slice_test_setup["process_id"],
  376. "filament_preset_id": slice_test_setup["filament_id"],
  377. },
  378. )
  379. assert response.status_code == 202
  380. final = await _wait_for_job(async_client, response.json()["job_id"])
  381. assert final["status"] == "completed", final
  382. assert final["result"]["used_embedded_settings"] is True
  383. assert call_count["n"] == 2 # primary + fallback retry
  384. @pytest.mark.asyncio
  385. @pytest.mark.integration
  386. async def test_stl_does_not_fall_back_on_cli_failure(self, async_client: AsyncClient, slice_test_setup):
  387. # STL has no embedded settings — the CLI 5xx is terminal.
  388. call_count = {"n": 0}
  389. def handler(_: httpx.Request) -> httpx.Response:
  390. call_count["n"] += 1
  391. return httpx.Response(
  392. status_code=500,
  393. json={"message": "Failed to slice the model"},
  394. )
  395. _install_mock_sidecar(handler)
  396. response = await async_client.post(
  397. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  398. json={
  399. "printer_preset_id": slice_test_setup["printer_id"],
  400. "process_preset_id": slice_test_setup["process_id"],
  401. "filament_preset_id": slice_test_setup["filament_id"],
  402. },
  403. )
  404. assert response.status_code == 202
  405. final = await _wait_for_job(async_client, response.json()["job_id"])
  406. assert final["status"] == "failed"
  407. assert final["error_status"] == 502
  408. assert call_count["n"] == 1 # No retry for STL
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. async def test_3mf_input_forwarded_unmodified_to_sidecar(
  412. self, async_client: AsyncClient, db_session, slice_test_setup
  413. ):
  414. # 3MF input must be forwarded to the sidecar verbatim — every
  415. # Metadata/*.config the source carries (project_settings,
  416. # model_settings, slice_info, cut_information) is needed by the
  417. # CLI to find plate definitions and baseline config; an earlier
  418. # version of this code stripped them and caused the CLI to
  419. # silently exit immediately after "Initializing StaticPrintConfigs"
  420. # for every 3MF slice. --load-settings overrides the specific
  421. # fields the user changed; the rest comes from the embedded data.
  422. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
  423. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  424. threemf = LibraryFile(
  425. filename="real.3mf",
  426. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  427. file_type="3mf",
  428. file_size=src_3mf_path.stat().st_size,
  429. )
  430. db_session.add(threemf)
  431. await db_session.commit()
  432. await db_session.refresh(threemf)
  433. captured: dict = {}
  434. def handler(request: httpx.Request) -> httpx.Response:
  435. captured["body"] = request.content
  436. return httpx.Response(
  437. status_code=200,
  438. content=b"PK\x03\x04 fake-3mf",
  439. headers={
  440. "x-print-time-seconds": "1",
  441. "x-filament-used-g": "0",
  442. "x-filament-used-mm": "0",
  443. },
  444. )
  445. _install_mock_sidecar(handler)
  446. response = await async_client.post(
  447. f"/api/v1/library/files/{threemf.id}/slice",
  448. json={
  449. "printer_preset_id": slice_test_setup["printer_id"],
  450. "process_preset_id": slice_test_setup["process_id"],
  451. "filament_preset_id": slice_test_setup["filament_id"],
  452. },
  453. )
  454. assert response.status_code == 202
  455. final = await _wait_for_job(async_client, response.json()["job_id"])
  456. assert final["status"] == "completed", final
  457. # Recover the embedded zip from the multipart body and assert ALL
  458. # the source's Metadata/*.config files are still present — the
  459. # opposite of the previous (broken) "strip everything" test.
  460. body = captured["body"]
  461. pk = body.find(b"PK\x03\x04")
  462. assert pk >= 0, "3MF body not found in multipart payload"
  463. with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
  464. names = set(zin.namelist())
  465. assert "Metadata/project_settings.config" in names
  466. assert "Metadata/model_settings.config" in names
  467. assert "Metadata/slice_info.config" in names
  468. assert "Metadata/cut_information.xml" in names
  469. assert "3D/3dmodel.model" in names
  470. # ---------------------------------------------------------------------------
  471. # GET /slice-jobs/{id}
  472. # ---------------------------------------------------------------------------
  473. class TestSliceJobs:
  474. @pytest.mark.asyncio
  475. @pytest.mark.integration
  476. async def test_unknown_job_returns_404(self, async_client: AsyncClient):
  477. # Sweep dispatcher state so a fresh ID is unknown.
  478. slice_dispatch._jobs.clear()
  479. r = await async_client.get("/api/v1/slice-jobs/999999")
  480. assert r.status_code == 404
  481. # ---------------------------------------------------------------------------
  482. # POST /archives/{id}/slice — re-sliced archive reflects the target printer
  483. # ---------------------------------------------------------------------------
  484. def _make_sliced_3mf(printer_model_id: str, bed_type: str | None = None) -> bytes:
  485. """A minimal sliced-output 3MF that embeds a printer_model_id in
  486. slice_info.config, the way a real Bambu Studio / OrcaSlicer export does.
  487. ThreeMFParser reads this into metadata['sliced_for_model']. When
  488. ``bed_type`` is set, also embed ``curr_bed_type`` so the parser surfaces
  489. ``metadata['bed_type']`` — needed for the bed-type lift assertion in
  490. TestSliceArchiveReslicedBedType."""
  491. extra_meta = f"<metadata key='curr_bed_type' value='{bed_type}'/>" if bed_type else ""
  492. buf = io.BytesIO()
  493. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  494. zf.writestr("3D/3dmodel.model", "<model/>")
  495. zf.writestr(
  496. "Metadata/slice_info.config",
  497. (
  498. "<config><plate>"
  499. f"<metadata key='printer_model_id' value='{printer_model_id}'/>"
  500. f"{extra_meta}"
  501. "</plate></config>"
  502. ),
  503. )
  504. return buf.getvalue()
  505. class TestCrossClassSliceAllLoop:
  506. """#1493: when the user picks "Slice all plates" on a cross-class source
  507. (X1C → H2D), Bambuddy must NOT send a single ``--slice 0 --arrange 1``
  508. call — that consolidates every plate's objects onto one bed via BS's
  509. project-wide arrange. Instead it loops per plate (``plate=N, arrange=true``)
  510. and merges the N single-plate outputs into one multi-plate 3MF locally.
  511. This test mocks the sidecar to assert (a) N calls happen, one per plate,
  512. each with arrange=true, and (b) the resulting archive's stored 3MF
  513. contains plate_1..plate_N.gcode entries."""
  514. @staticmethod
  515. def _make_multi_plate_x1c_source(plate_count: int = 3) -> bytes:
  516. """Source 3MF: X1C-stamped, N plates declared via model_settings."""
  517. plate_blocks = "\n".join(
  518. f'<plate><metadata key="plater_id" value="{i}"/></plate>' for i in range(1, plate_count + 1)
  519. )
  520. buf = io.BytesIO()
  521. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  522. zf.writestr("3D/3dmodel.model", "<model/>")
  523. zf.writestr(
  524. "Metadata/project_settings.config",
  525. json.dumps({"printer_model": "Bambu Lab X1 Carbon"}),
  526. )
  527. zf.writestr(
  528. "Metadata/model_settings.config",
  529. f"<?xml version='1.0'?>\n<config>\n{plate_blocks}\n</config>\n",
  530. )
  531. return buf.getvalue()
  532. @staticmethod
  533. def _make_single_plate_sliced_output(plate_num: int) -> bytes:
  534. """Mock per-plate output: looks like what BS CLI returns for
  535. --slice N. Carries an H2D project_settings (target), a one-line
  536. slice_info <plate> block, and a per-plate gcode + thumbnail."""
  537. buf = io.BytesIO()
  538. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  539. zf.writestr("3D/3dmodel.model", "<model/>")
  540. zf.writestr(
  541. "Metadata/project_settings.config",
  542. json.dumps({"printer_model": "Bambu Lab H2D"}),
  543. )
  544. zf.writestr("Metadata/model_settings.config", "<config/>")
  545. zf.writestr(
  546. "Metadata/slice_info.config",
  547. f"<config><plate><metadata key='index' value='{plate_num}'/>"
  548. f"<metadata key='printer_model_id' value='O1D'/></plate></config>",
  549. )
  550. zf.writestr(f"Metadata/plate_{plate_num}.gcode", f"G{plate_num}".encode())
  551. zf.writestr(f"Metadata/plate_{plate_num}.gcode.md5", b"deadbeef")
  552. zf.writestr(f"Metadata/plate_{plate_num}.json", b"{}")
  553. zf.writestr(f"Metadata/plate_{plate_num}.png", f"P{plate_num}".encode())
  554. return buf.getvalue()
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_loops_per_plate_when_cross_class_with_plate_zero(
  558. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  559. ):
  560. from backend.app.models.archive import PrintArchive
  561. tmp_path = slice_test_setup["tmp_path"]
  562. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  563. src_dir = tmp_path / "archives" / "src"
  564. src_dir.mkdir(parents=True, exist_ok=True)
  565. src_3mf = src_dir / "mewtwo.3mf"
  566. src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=3))
  567. printer = await printer_factory()
  568. source = await archive_factory(
  569. printer.id,
  570. filename="mewtwo.3mf",
  571. file_path=str(src_3mf.relative_to(tmp_path)),
  572. sliced_for_model="X1C",
  573. with_run=False,
  574. )
  575. # H2D target preset — the cross-class detector reads the
  576. # ``printer_model`` field off the resolved JSON.
  577. h2d = LocalPreset(
  578. name="# Bambu Lab H2D 0.4 nozzle",
  579. preset_type="printer",
  580. source="orcaslicer",
  581. setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
  582. )
  583. db_session.add(h2d)
  584. await db_session.commit()
  585. await db_session.refresh(h2d)
  586. # Mock sidecar: capture every request and respond with that
  587. # plate's single-plate output. We expect one request per plate
  588. # in the source (3 here).
  589. captured_requests: list[dict] = []
  590. def handler(request: httpx.Request) -> httpx.Response:
  591. # Multipart bodies aren't trivially parseable here; pull
  592. # the plate field by string search since the helper sends
  593. # ``name="plate"`` immediately followed by the value.
  594. body = request.content
  595. plate = None
  596. marker = b'name="plate"\r\n\r\n'
  597. idx = body.find(marker)
  598. if idx != -1:
  599. # Find the next CRLF after the value start.
  600. start = idx + len(marker)
  601. end = body.find(b"\r\n", start)
  602. try:
  603. plate = int(body[start:end].decode("utf-8"))
  604. except (UnicodeDecodeError, ValueError):
  605. plate = None
  606. arrange_in_body = b'name="arrange"' in body
  607. captured_requests.append({"plate": plate, "arrange": arrange_in_body})
  608. return httpx.Response(
  609. status_code=200,
  610. content=self._make_single_plate_sliced_output(plate or 1),
  611. headers={
  612. "x-print-time-seconds": "600",
  613. "x-filament-used-g": "5.0",
  614. "x-filament-used-mm": "1600.0",
  615. },
  616. )
  617. _install_mock_sidecar(handler)
  618. # plate=0 + cross-class triplet → backend should enter the
  619. # per-plate loop, slice each of the 3 plates with arrange=True,
  620. # and merge into one archive.
  621. resp = await async_client.post(
  622. f"/api/v1/archives/{source.id}/slice",
  623. json={
  624. "printer_preset": {"source": "local", "id": str(h2d.id)},
  625. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  626. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  627. "plate": 0,
  628. },
  629. )
  630. assert resp.status_code == 202, resp.text
  631. final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
  632. assert final["status"] == "completed", final
  633. # Exactly one sidecar call per plate, in plate order. The
  634. # ``--arrange 1`` flag travels with every per-plate sub-slice
  635. # (it's what fixes the cross-class boundary error).
  636. plates_called = [c["plate"] for c in captured_requests]
  637. arrange_used = [c["arrange"] for c in captured_requests]
  638. assert plates_called == [1, 2, 3], plates_called
  639. assert all(arrange_used), arrange_used
  640. # The merged archive has plate_1..plate_3.gcode inside its one
  641. # output 3MF (single Bambuddy archive, three plates).
  642. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  643. archive_path = tmp_path / new_archive.file_path
  644. with zipfile.ZipFile(archive_path, "r") as zf:
  645. entries = set(zf.namelist())
  646. assert "Metadata/plate_1.gcode" in entries
  647. assert "Metadata/plate_2.gcode" in entries
  648. assert "Metadata/plate_3.gcode" in entries
  649. # Per-plate-result totals are summed onto the merged archive.
  650. assert new_archive.print_time_seconds == 600 * 3
  651. assert new_archive.filament_used_grams == pytest.approx(5.0 * 3)
  652. class TestSliceArchiveResliceModel:
  653. """Re-slicing an archive for a different printer must stamp the new
  654. archive with the printer it was sliced FOR, not the source's printer."""
  655. @pytest.mark.asyncio
  656. @pytest.mark.integration
  657. async def test_reslice_uses_target_model_not_source_model(
  658. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  659. ):
  660. from backend.app.models.archive import PrintArchive
  661. tmp_path = slice_test_setup["tmp_path"]
  662. # archive_dir is a static path off the real data dir; point it under
  663. # base_dir (= tmp_path) so the new archive's file resolves there.
  664. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  665. # Source archive: a 3MF that was sliced for an X1C.
  666. src_dir = tmp_path / "archives" / "src"
  667. src_dir.mkdir(parents=True, exist_ok=True)
  668. src_3mf = src_dir / "cube.3mf"
  669. src_3mf.write_bytes(_make_3mf_with_settings())
  670. printer = await printer_factory()
  671. source = await archive_factory(
  672. printer.id,
  673. filename="cube.3mf",
  674. file_path=str(src_3mf.relative_to(tmp_path)),
  675. sliced_for_model="X1C",
  676. with_run=False,
  677. )
  678. source_id = source.id
  679. # The slicer returns a 3MF whose embedded printer_model_id is O1D (H2D).
  680. def handler(request: httpx.Request) -> httpx.Response:
  681. return httpx.Response(
  682. status_code=200,
  683. content=_make_sliced_3mf("O1D"),
  684. headers={
  685. "x-print-time-seconds": "600",
  686. "x-filament-used-g": "5.0",
  687. "x-filament-used-mm": "1600.0",
  688. },
  689. )
  690. _install_mock_sidecar(handler)
  691. resp = await async_client.post(
  692. f"/api/v1/archives/{source_id}/slice",
  693. json={
  694. "printer_preset_id": slice_test_setup["printer_id"],
  695. "process_preset_id": slice_test_setup["process_id"],
  696. "filament_preset_id": slice_test_setup["filament_id"],
  697. },
  698. )
  699. assert resp.status_code == 202, resp.text
  700. final = await _wait_for_job(async_client, resp.json()["job_id"])
  701. assert final["status"] == "completed", final
  702. new_id = final["result"]["archive_id"]
  703. assert new_id != source_id
  704. new_archive = await db_session.get(PrintArchive, new_id)
  705. # The fix: the re-sliced archive reflects H2D — the printer it was
  706. # sliced for — instead of inheriting X1C from the source archive.
  707. assert new_archive.sliced_for_model == "H2D"
  708. # Source archive is untouched.
  709. source_reloaded = await db_session.get(PrintArchive, source_id)
  710. assert source_reloaded.sliced_for_model == "X1C"
  711. @pytest.mark.asyncio
  712. @pytest.mark.integration
  713. async def test_cross_model_reslice_drops_source_printer_id(
  714. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  715. ):
  716. """A cross-model re-slice (source's X1C → target's H2D) must not carry
  717. over ``source.printer_id``. The archive card and reprint modal both
  718. read ``printer_id`` first and only fall back to ``sliced_for_model``
  719. when it's None, so leaving the inherited id makes the H2D-sliced card
  720. display the source's X1C printer name (the "Workshop H2C" bug)."""
  721. from backend.app.models.archive import PrintArchive
  722. tmp_path = slice_test_setup["tmp_path"]
  723. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  724. src_dir = tmp_path / "archives" / "src"
  725. src_dir.mkdir(parents=True, exist_ok=True)
  726. src_3mf = src_dir / "cube.3mf"
  727. src_3mf.write_bytes(_make_3mf_with_settings())
  728. source_printer = await printer_factory()
  729. source = await archive_factory(
  730. source_printer.id,
  731. filename="cube.3mf",
  732. file_path=str(src_3mf.relative_to(tmp_path)),
  733. sliced_for_model="X1C",
  734. with_run=False,
  735. )
  736. source_id = source.id
  737. source_printer_id = source_printer.id
  738. def handler(request: httpx.Request) -> httpx.Response:
  739. return httpx.Response(
  740. status_code=200,
  741. content=_make_sliced_3mf("O1D"), # H2D
  742. headers={
  743. "x-print-time-seconds": "600",
  744. "x-filament-used-g": "5.0",
  745. "x-filament-used-mm": "1600.0",
  746. },
  747. )
  748. _install_mock_sidecar(handler)
  749. resp = await async_client.post(
  750. f"/api/v1/archives/{source_id}/slice",
  751. json={
  752. "printer_preset_id": slice_test_setup["printer_id"],
  753. "process_preset_id": slice_test_setup["process_id"],
  754. "filament_preset_id": slice_test_setup["filament_id"],
  755. },
  756. )
  757. assert resp.status_code == 202, resp.text
  758. final = await _wait_for_job(async_client, resp.json()["job_id"])
  759. assert final["status"] == "completed", final
  760. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  761. assert new_archive.sliced_for_model == "H2D"
  762. # Card / reprint modal will now fall back to the sliced_for_model
  763. # badge instead of showing the source printer's name.
  764. assert new_archive.printer_id is None
  765. # Source untouched: still bound to its original printer.
  766. source_reloaded = await db_session.get(PrintArchive, source_id)
  767. assert source_reloaded.printer_id == source_printer_id
  768. @pytest.mark.asyncio
  769. @pytest.mark.integration
  770. async def test_same_model_reslice_preserves_source_printer_id(
  771. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  772. ):
  773. """Same-model re-slice (X1C → X1C, e.g. just swapped a process preset)
  774. keeps ``printer_id`` so the reprint modal pre-selects the original
  775. printer. Only cross-model re-slices null it out."""
  776. from backend.app.models.archive import PrintArchive
  777. tmp_path = slice_test_setup["tmp_path"]
  778. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  779. src_dir = tmp_path / "archives" / "src"
  780. src_dir.mkdir(parents=True, exist_ok=True)
  781. src_3mf = src_dir / "cube.3mf"
  782. src_3mf.write_bytes(_make_3mf_with_settings())
  783. source_printer = await printer_factory()
  784. source = await archive_factory(
  785. source_printer.id,
  786. filename="cube.3mf",
  787. file_path=str(src_3mf.relative_to(tmp_path)),
  788. sliced_for_model="X1C",
  789. with_run=False,
  790. )
  791. def handler(request: httpx.Request) -> httpx.Response:
  792. return httpx.Response(
  793. status_code=200,
  794. content=_make_sliced_3mf("C11"), # X1C — same model as source
  795. headers={
  796. "x-print-time-seconds": "600",
  797. "x-filament-used-g": "5.0",
  798. "x-filament-used-mm": "1600.0",
  799. },
  800. )
  801. _install_mock_sidecar(handler)
  802. resp = await async_client.post(
  803. f"/api/v1/archives/{source.id}/slice",
  804. json={
  805. "printer_preset_id": slice_test_setup["printer_id"],
  806. "process_preset_id": slice_test_setup["process_id"],
  807. "filament_preset_id": slice_test_setup["filament_id"],
  808. },
  809. )
  810. assert resp.status_code == 202, resp.text
  811. final = await _wait_for_job(async_client, resp.json()["job_id"])
  812. assert final["status"] == "completed", final
  813. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  814. assert new_archive.sliced_for_model == "X1C"
  815. # Same-model: keep the source's printer assignment so reprint pre-selects it.
  816. assert new_archive.printer_id == source_printer.id
  817. @pytest.mark.asyncio
  818. @pytest.mark.integration
  819. async def test_reslice_with_unknown_source_model_preserves_printer_id(
  820. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  821. ):
  822. """When ``source.sliced_for_model`` is None (older archive that
  823. predates that column being populated), the backend can't tell whether
  824. this is a cross-model re-slice. Fail open and preserve ``printer_id``
  825. rather than spuriously nulling it — current pre-fix behaviour, kept
  826. as a deliberate edge case."""
  827. from backend.app.models.archive import PrintArchive
  828. tmp_path = slice_test_setup["tmp_path"]
  829. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  830. src_dir = tmp_path / "archives" / "src"
  831. src_dir.mkdir(parents=True, exist_ok=True)
  832. src_3mf = src_dir / "cube.3mf"
  833. src_3mf.write_bytes(_make_3mf_with_settings())
  834. source_printer = await printer_factory()
  835. source = await archive_factory(
  836. source_printer.id,
  837. filename="cube.3mf",
  838. file_path=str(src_3mf.relative_to(tmp_path)),
  839. sliced_for_model=None,
  840. with_run=False,
  841. )
  842. def handler(request: httpx.Request) -> httpx.Response:
  843. return httpx.Response(
  844. status_code=200,
  845. content=_make_sliced_3mf("O1D"),
  846. headers={
  847. "x-print-time-seconds": "600",
  848. "x-filament-used-g": "5.0",
  849. "x-filament-used-mm": "1600.0",
  850. },
  851. )
  852. _install_mock_sidecar(handler)
  853. resp = await async_client.post(
  854. f"/api/v1/archives/{source.id}/slice",
  855. json={
  856. "printer_preset_id": slice_test_setup["printer_id"],
  857. "process_preset_id": slice_test_setup["process_id"],
  858. "filament_preset_id": slice_test_setup["filament_id"],
  859. },
  860. )
  861. assert resp.status_code == 202, resp.text
  862. final = await _wait_for_job(async_client, resp.json()["job_id"])
  863. assert final["status"] == "completed", final
  864. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  865. # Insufficient info to decide cross-model → preserve printer_id.
  866. assert new_archive.printer_id == source_printer.id
  867. class TestSliceArchiveReslicedThumbnail:
  868. """#1493 follow-up: the re-sliced archive's cover image preference order is
  869. source's per-plate render > sliced output's per-plate render >
  870. Auxiliaries marketing thumbnail. BS CLI rarely writes a fresh
  871. ``Metadata/plate_N.png`` on the sliced output, so the source's render
  872. of the same plate (closer to what's actually printing) wins over the
  873. project-wide marketing image."""
  874. @staticmethod
  875. def _make_source_with_plate_png(plate_png_bytes: bytes) -> bytes:
  876. buf = io.BytesIO()
  877. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  878. zf.writestr("3D/3dmodel.model", "<model/>")
  879. zf.writestr("Metadata/plate_1.png", plate_png_bytes)
  880. # Project-wide marketing image — the unwanted fallback target.
  881. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART")
  882. return buf.getvalue()
  883. @pytest.mark.asyncio
  884. @pytest.mark.integration
  885. async def test_uses_source_plate_png_when_sliced_output_lacks_one(
  886. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  887. ):
  888. """Sliced output has no per-plate PNG (typical of BS CLI output
  889. with --arrange). The source's plate_1.png must win over the
  890. sliced output's Auxiliaries fallback."""
  891. from backend.app.models.archive import PrintArchive
  892. tmp_path = slice_test_setup["tmp_path"]
  893. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  894. # Source has its own plate_1.png AND a project-wide cover.
  895. source_plate_marker = b"SOURCE_PLATE_RENDER"
  896. src_dir = tmp_path / "archives" / "src"
  897. src_dir.mkdir(parents=True, exist_ok=True)
  898. src_3mf = src_dir / "cube.3mf"
  899. src_3mf.write_bytes(self._make_source_with_plate_png(source_plate_marker))
  900. printer = await printer_factory()
  901. source = await archive_factory(
  902. printer.id,
  903. filename="cube.3mf",
  904. file_path=str(src_3mf.relative_to(tmp_path)),
  905. sliced_for_model="X1C",
  906. with_run=False,
  907. )
  908. # Mock slicer returns a 3MF with NO Metadata/plate_1.png — only
  909. # the Auxiliaries cover, mimicking BS CLI output with --arrange.
  910. def handler(request: httpx.Request) -> httpx.Response:
  911. sliced_buf = io.BytesIO()
  912. with zipfile.ZipFile(sliced_buf, "w") as zf:
  913. zf.writestr("3D/3dmodel.model", "<model/>")
  914. zf.writestr("Metadata/slice_info.config", "<config/>")
  915. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"SLICED_COVER_ART")
  916. return httpx.Response(
  917. status_code=200,
  918. content=sliced_buf.getvalue(),
  919. headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"},
  920. )
  921. _install_mock_sidecar(handler)
  922. resp = await async_client.post(
  923. f"/api/v1/archives/{source.id}/slice",
  924. json={
  925. "printer_preset_id": slice_test_setup["printer_id"],
  926. "process_preset_id": slice_test_setup["process_id"],
  927. "filament_preset_id": slice_test_setup["filament_id"],
  928. },
  929. )
  930. assert resp.status_code == 202, resp.text
  931. final = await _wait_for_job(async_client, resp.json()["job_id"])
  932. assert final["status"] == "completed", final
  933. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  934. assert new.thumbnail_path is not None
  935. thumb_full = tmp_path / new.thumbnail_path
  936. assert thumb_full.read_bytes() == source_plate_marker, (
  937. "Re-sliced archive's thumbnail should be the source's per-plate render, not the Auxiliaries cover art."
  938. )
  939. @pytest.mark.asyncio
  940. @pytest.mark.integration
  941. async def test_falls_back_to_auxiliaries_when_source_lacks_plate_png(
  942. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  943. ):
  944. """When the source has no per-plate render (unsliced library upload),
  945. the Auxiliaries marketing image from the sliced output is the
  946. next-best preview — better than no card thumbnail at all."""
  947. from backend.app.models.archive import PrintArchive
  948. tmp_path = slice_test_setup["tmp_path"]
  949. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  950. # Source has no Metadata/plate_1.png at all.
  951. bare_buf = io.BytesIO()
  952. with zipfile.ZipFile(bare_buf, "w") as zf:
  953. zf.writestr("3D/3dmodel.model", "<model/>")
  954. src_dir = tmp_path / "archives" / "src"
  955. src_dir.mkdir(parents=True, exist_ok=True)
  956. src_3mf = src_dir / "bare.3mf"
  957. src_3mf.write_bytes(bare_buf.getvalue())
  958. printer = await printer_factory()
  959. source = await archive_factory(
  960. printer.id,
  961. filename="bare.3mf",
  962. file_path=str(src_3mf.relative_to(tmp_path)),
  963. sliced_for_model="X1C",
  964. with_run=False,
  965. )
  966. def handler(request: httpx.Request) -> httpx.Response:
  967. sliced_buf = io.BytesIO()
  968. with zipfile.ZipFile(sliced_buf, "w") as zf:
  969. zf.writestr("3D/3dmodel.model", "<model/>")
  970. zf.writestr("Metadata/slice_info.config", "<config/>")
  971. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART_FALLBACK")
  972. return httpx.Response(
  973. status_code=200,
  974. content=sliced_buf.getvalue(),
  975. headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"},
  976. )
  977. _install_mock_sidecar(handler)
  978. resp = await async_client.post(
  979. f"/api/v1/archives/{source.id}/slice",
  980. json={
  981. "printer_preset_id": slice_test_setup["printer_id"],
  982. "process_preset_id": slice_test_setup["process_id"],
  983. "filament_preset_id": slice_test_setup["filament_id"],
  984. },
  985. )
  986. assert resp.status_code == 202, resp.text
  987. final = await _wait_for_job(async_client, resp.json()["job_id"])
  988. assert final["status"] == "completed", final
  989. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  990. assert new.thumbnail_path is not None
  991. thumb_full = tmp_path / new.thumbnail_path
  992. assert thumb_full.read_bytes() == b"COVER_ART_FALLBACK"
  993. class TestSliceArchiveReslicedBedType:
  994. """#1493 follow-up: the re-sliced archive's ``bed_type`` column must be
  995. set from the produced 3MF's ``curr_bed_type`` so the frontend's archive
  996. card shows the right build-plate badge (the card reads the column, not
  997. extra_data, so the value was previously invisible after a re-slice)."""
  998. @pytest.mark.asyncio
  999. @pytest.mark.integration
  1000. async def test_bed_type_lifted_from_sliced_output(
  1001. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1002. ):
  1003. from backend.app.models.archive import PrintArchive
  1004. tmp_path = slice_test_setup["tmp_path"]
  1005. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1006. src_dir = tmp_path / "archives" / "src"
  1007. src_dir.mkdir(parents=True, exist_ok=True)
  1008. src_3mf = src_dir / "cube.3mf"
  1009. src_3mf.write_bytes(_make_3mf_with_settings())
  1010. printer = await printer_factory()
  1011. source = await archive_factory(
  1012. printer.id,
  1013. filename="cube.3mf",
  1014. file_path=str(src_3mf.relative_to(tmp_path)),
  1015. sliced_for_model="X1C",
  1016. bed_type="Cool Plate",
  1017. with_run=False,
  1018. )
  1019. # Mock slicer: produced 3MF declares a different plate type than
  1020. # the source archive's ``Cool Plate``. The new column must reflect
  1021. # the slicer's value (the user picked a different plate in the
  1022. # SliceModal) instead of inheriting the source's.
  1023. def handler(request: httpx.Request) -> httpx.Response:
  1024. return httpx.Response(
  1025. status_code=200,
  1026. content=_make_sliced_3mf("O1D", bed_type="Textured PEI Plate"),
  1027. headers={
  1028. "x-print-time-seconds": "600",
  1029. "x-filament-used-g": "5.0",
  1030. "x-filament-used-mm": "1600.0",
  1031. },
  1032. )
  1033. _install_mock_sidecar(handler)
  1034. resp = await async_client.post(
  1035. f"/api/v1/archives/{source.id}/slice",
  1036. json={
  1037. "printer_preset_id": slice_test_setup["printer_id"],
  1038. "process_preset_id": slice_test_setup["process_id"],
  1039. "filament_preset_id": slice_test_setup["filament_id"],
  1040. },
  1041. )
  1042. assert resp.status_code == 202, resp.text
  1043. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1044. assert final["status"] == "completed", final
  1045. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1046. assert new.bed_type == "Textured PEI Plate"
  1047. @pytest.mark.asyncio
  1048. @pytest.mark.integration
  1049. async def test_bed_type_falls_back_to_source_when_missing_from_output(
  1050. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1051. ):
  1052. """An older sidecar or sparse slice profile may produce a 3MF without
  1053. ``curr_bed_type``. The source archive's ``bed_type`` is the right
  1054. default in that case — better than leaving the badge blank."""
  1055. from backend.app.models.archive import PrintArchive
  1056. tmp_path = slice_test_setup["tmp_path"]
  1057. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1058. src_dir = tmp_path / "archives" / "src"
  1059. src_dir.mkdir(parents=True, exist_ok=True)
  1060. src_3mf = src_dir / "cube.3mf"
  1061. src_3mf.write_bytes(_make_3mf_with_settings())
  1062. printer = await printer_factory()
  1063. source = await archive_factory(
  1064. printer.id,
  1065. filename="cube.3mf",
  1066. file_path=str(src_3mf.relative_to(tmp_path)),
  1067. sliced_for_model="X1C",
  1068. bed_type="Cool Plate",
  1069. with_run=False,
  1070. )
  1071. def handler(request: httpx.Request) -> httpx.Response:
  1072. return httpx.Response(
  1073. status_code=200,
  1074. # No bed_type embedded — simulates a sidecar that drops it.
  1075. content=_make_sliced_3mf("O1D"),
  1076. headers={
  1077. "x-print-time-seconds": "600",
  1078. "x-filament-used-g": "5.0",
  1079. "x-filament-used-mm": "1600.0",
  1080. },
  1081. )
  1082. _install_mock_sidecar(handler)
  1083. resp = await async_client.post(
  1084. f"/api/v1/archives/{source.id}/slice",
  1085. json={
  1086. "printer_preset_id": slice_test_setup["printer_id"],
  1087. "process_preset_id": slice_test_setup["process_id"],
  1088. "filament_preset_id": slice_test_setup["filament_id"],
  1089. },
  1090. )
  1091. assert resp.status_code == 202, resp.text
  1092. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1093. assert final["status"] == "completed", final
  1094. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1095. assert new.bed_type == "Cool Plate"
  1096. # ---------------------------------------------------------------------------
  1097. # Slicer content rejections surface instead of silently falling back
  1098. # ---------------------------------------------------------------------------
  1099. class TestSlicerRejectionMessage:
  1100. """_slicer_rejection_message distinguishes a real slicer content rejection
  1101. (surface it to the user) from a CLI crash (fall back to embedded)."""
  1102. def test_extracts_bed_boundary_reason(self):
  1103. text = (
  1104. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1105. "Some objects are located over the boundary of the heated bed.: "
  1106. "Slicer process failed (exit code 204)\nstdout: trace ..."
  1107. )
  1108. assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
  1109. def test_extracts_filament_temp_reason(self):
  1110. text = (
  1111. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1112. "The temperature difference of the filaments used is too large.: "
  1113. "Slicer process failed (exit code 194)"
  1114. )
  1115. assert _slicer_rejection_message(text) == "The temperature difference of the filaments used is too large."
  1116. def test_generic_cli_failure_is_not_a_rejection(self):
  1117. # The #1201 CLI-crash signature carries no slicer error_string, so it
  1118. # must still fall through to the embedded-settings fallback.
  1119. assert _slicer_rejection_message("Slicer CLI failed (500): Failed to slice the model") is None
  1120. def test_empty_or_unrelated_text(self):
  1121. assert _slicer_rejection_message("") is None
  1122. assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
  1123. def test_replaces_input_preset_invalid_placeholder_with_cli_error_line(self):
  1124. # #1851: the CLI emits its catch-all "input preset file is invalid"
  1125. # placeholder for every -5 exit, including real preset-vs-printer
  1126. # compatibility rejections. The actual diagnostic only appears in the
  1127. # stdout `[error] run NNNN:` line; the function must prefer that.
  1128. text = (
  1129. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1130. "The input preset file is invalid and can not be parsed.: "
  1131. "Slicer process failed (exit code 251)\n"
  1132. "stdout: [2026-06-29 04:12:11.952784] [trace] Initializing StaticPrintConfigs\n"
  1133. "[2026-06-29 04:12:12.175810] [error] run 3008: filament preset "
  1134. "Generic PLA @BBL H2C (slot 1) is not compatible with printer "
  1135. "Bambu Lab A1 0.4 nozzle.\n"
  1136. "run found error, return -5, exit..."
  1137. )
  1138. assert (
  1139. _slicer_rejection_message(text) == "filament preset Generic PLA @BBL H2C (slot 1) is not compatible with "
  1140. "printer Bambu Lab A1 0.4 nozzle."
  1141. )
  1142. def test_keeps_meaningful_reason_even_when_cli_error_line_present(self):
  1143. # When the headline error_string is already a useful reason (here:
  1144. # the bed-boundary rejection), don't override it with a generic
  1145. # `[error]` line that may just be the same message restated. Avoids
  1146. # double-text duplication in the user-facing detail.
  1147. text = (
  1148. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1149. "Some objects are located over the boundary of the heated bed.: "
  1150. "Slicer process failed (exit code 204)\n"
  1151. "stdout: [error] some unrelated stdout chatter"
  1152. )
  1153. assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
  1154. def test_cli_error_line_without_run_prefix(self):
  1155. # The CLI sometimes logs `[error] <msg>` without the `run NNNN:`
  1156. # prefix (different code paths). The regex must still pick it up.
  1157. text = (
  1158. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1159. "The input preset file is invalid and can not be parsed.: "
  1160. "Slicer process failed (exit code 251)\n"
  1161. "stdout: [2026-06-29 12:00:00.000000] [error] Configuration parse failed: missing key 'printer_settings_id'"
  1162. )
  1163. assert _slicer_rejection_message(text) == "Configuration parse failed: missing key 'printer_settings_id'"
  1164. class TestSliceSlicerRejection:
  1165. @pytest.mark.asyncio
  1166. @pytest.mark.integration
  1167. async def test_3mf_surfaces_slicer_rejection_instead_of_falling_back(
  1168. self, async_client: AsyncClient, db_session, slice_test_setup
  1169. ):
  1170. """A real slicer content rejection (e.g. re-slicing for a printer with
  1171. a smaller bed) must surface as a 400 — not silently fall back to the
  1172. source 3MF's embedded settings, which would re-slice for the original
  1173. printer and hide the problem."""
  1174. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "toobig.3mf"
  1175. src_3mf_path.write_bytes(_make_3mf_with_settings())
  1176. threemf = LibraryFile(
  1177. filename="toobig.3mf",
  1178. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  1179. file_type="3mf",
  1180. file_size=src_3mf_path.stat().st_size,
  1181. )
  1182. db_session.add(threemf)
  1183. await db_session.commit()
  1184. await db_session.refresh(threemf)
  1185. call_count = {"n": 0}
  1186. def handler(request: httpx.Request) -> httpx.Response:
  1187. call_count["n"] += 1
  1188. return httpx.Response(
  1189. status_code=500,
  1190. json={
  1191. "message": (
  1192. "Slicing failed with error from slicer: Some objects are "
  1193. "located over the boundary of the heated bed."
  1194. ),
  1195. "details": "Slicer process failed (exit code 204)",
  1196. },
  1197. )
  1198. _install_mock_sidecar(handler)
  1199. response = await async_client.post(
  1200. f"/api/v1/library/files/{threemf.id}/slice",
  1201. json={
  1202. "printer_preset_id": slice_test_setup["printer_id"],
  1203. "process_preset_id": slice_test_setup["process_id"],
  1204. "filament_preset_id": slice_test_setup["filament_id"],
  1205. },
  1206. )
  1207. assert response.status_code == 202
  1208. final = await _wait_for_job(async_client, response.json()["job_id"])
  1209. assert final["status"] == "failed", final
  1210. assert final["error_status"] == 400
  1211. assert "boundary of the heated bed" in (final["error_detail"] or "")
  1212. # The slicer rejection must NOT trigger the embedded-settings retry.
  1213. assert call_count["n"] == 1
  1214. # ---------------------------------------------------------------------------
  1215. # Nozzle-class re-slice guard — single-nozzle <-> dual-nozzle (H2D) is blocked
  1216. # ---------------------------------------------------------------------------
  1217. from fastapi import HTTPException # noqa: E402
  1218. from backend.app.api.routes.library import ( # noqa: E402
  1219. _canonical_printer_model,
  1220. guard_nozzle_class_reslice,
  1221. )
  1222. class TestCanonicalPrinterModel:
  1223. """_canonical_printer_model strips the '# ' clone prefix and the
  1224. ' 0.4 nozzle' variant suffix so preset names resolve to a model code."""
  1225. def test_strips_nozzle_suffix(self):
  1226. assert _canonical_printer_model("Bambu Lab H2D 0.4 nozzle") == "H2D"
  1227. def test_strips_clone_prefix_and_suffix(self):
  1228. assert _canonical_printer_model("# Bambu Lab X1 Carbon 0.4 nozzle") == "X1C"
  1229. def test_bare_model_and_empty(self):
  1230. assert _canonical_printer_model("Bambu Lab H2D") == "H2D"
  1231. assert _canonical_printer_model(None) is None
  1232. assert _canonical_printer_model("") is None
  1233. class TestNozzleClassGuard:
  1234. """guard_nozzle_class_reslice is now a no-op (#1493). Cross-class re-slicing
  1235. is handled by the two-pass conversion in _run_slicer_with_fallback — so the
  1236. guard never blocks. The function is kept (and these tests with it) so
  1237. external forks / pinned versions that call it still link, and so a future
  1238. regression that re-introduces a raise inside the helper gets caught here."""
  1239. @staticmethod
  1240. def _request() -> object:
  1241. return type("_Req", (), {})()
  1242. @pytest.mark.asyncio
  1243. async def test_single_to_dual_is_allowed(self, monkeypatch):
  1244. """Cross-class re-slice: handled by the two-pass converter, so the
  1245. guard does NOT raise."""
  1246. import backend.app.api.routes.library as lib
  1247. async def _target(_db, _user, _request):
  1248. return "H2D"
  1249. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1250. # No raise — the converter handles this case now.
  1251. await guard_nozzle_class_reslice(None, None, self._request(), "X1C")
  1252. @pytest.mark.asyncio
  1253. async def test_dual_to_single_is_allowed(self, monkeypatch):
  1254. import backend.app.api.routes.library as lib
  1255. async def _target(_db, _user, _request):
  1256. return "X1C"
  1257. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1258. await guard_nozzle_class_reslice(None, None, self._request(), "H2D")
  1259. @pytest.mark.asyncio
  1260. async def test_same_nozzle_class_is_allowed(self, monkeypatch):
  1261. import backend.app.api.routes.library as lib
  1262. async def _target(_db, _user, _request):
  1263. return "P1S"
  1264. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1265. await guard_nozzle_class_reslice(None, None, self._request(), "X1C")
  1266. @pytest.mark.asyncio
  1267. async def test_no_source_model_is_a_noop(self, monkeypatch):
  1268. import backend.app.api.routes.library as lib
  1269. async def _target(_db, _user, _request):
  1270. return "H2D"
  1271. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1272. await guard_nozzle_class_reslice(None, None, self._request(), None)
  1273. @pytest.mark.asyncio
  1274. async def test_null_request_is_a_noop(self):
  1275. await guard_nozzle_class_reslice(None, None, None, "X1C")
  1276. @pytest.mark.asyncio
  1277. @pytest.mark.integration
  1278. async def test_archive_reslice_x1c_to_h2d_preset_path_is_not_400(
  1279. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1280. ):
  1281. """End to end: the preset-driven archive re-slice from X1C to H2D no
  1282. longer gets a synchronous 400 from the guard. It may still fail
  1283. downstream (no sidecar in test env), but it must not be rejected by
  1284. the nozzle-class guard's old "isn't supported yet" message."""
  1285. tmp_path = slice_test_setup["tmp_path"]
  1286. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1287. src_dir = tmp_path / "archives" / "src"
  1288. src_dir.mkdir(parents=True, exist_ok=True)
  1289. src_3mf = src_dir / "cube.3mf"
  1290. src_3mf.write_bytes(_make_3mf_with_settings())
  1291. printer = await printer_factory()
  1292. source = await archive_factory(
  1293. printer.id,
  1294. filename="cube.3mf",
  1295. file_path=str(src_3mf.relative_to(tmp_path)),
  1296. sliced_for_model="X1C",
  1297. with_run=False,
  1298. )
  1299. h2d = LocalPreset(
  1300. name="# Bambu Lab H2D 0.4 nozzle",
  1301. preset_type="printer",
  1302. source="orcaslicer",
  1303. setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
  1304. )
  1305. db_session.add(h2d)
  1306. await db_session.commit()
  1307. await db_session.refresh(h2d)
  1308. resp = await async_client.post(
  1309. f"/api/v1/archives/{source.id}/slice",
  1310. json={
  1311. "printer_preset": {"source": "local", "id": str(h2d.id)},
  1312. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  1313. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  1314. },
  1315. )
  1316. if resp.status_code == 400:
  1317. detail = resp.json().get("detail", "")
  1318. assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"