test_library_slice_api.py 105 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463
  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. def _is_slice_post(request: httpx.Request) -> bool:
  59. """True for the slice call itself, false for the progress polls beside it.
  60. Since #2730 a slice is supervised by a 1 Hz poll of
  61. ``GET /slice/progress/{id}``, which shares this mock transport. Tests that
  62. count *slice attempts* — primary vs embedded-settings fallback — have to
  63. exclude those, or the count becomes a measure of how long the test took.
  64. """
  65. return request.method == "POST" and request.url.path.endswith("/slice")
  66. async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
  67. """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
  68. The dispatcher runs work as an asyncio task on the same event loop, so
  69. poll-with-sleep here is enough — a few yields and the task finishes.
  70. """
  71. deadline = asyncio.get_event_loop().time() + timeout
  72. while asyncio.get_event_loop().time() < deadline:
  73. r = await client.get(f"/api/v1/slice-jobs/{job_id}")
  74. if r.status_code != 200:
  75. raise AssertionError(f"slice-jobs poll failed: {r.status_code} {r.text}")
  76. body = r.json()
  77. if body["status"] in ("completed", "failed"):
  78. return body
  79. await asyncio.sleep(0.05)
  80. raise AssertionError(f"slice job {job_id} did not finish in {timeout}s")
  81. # ---------------------------------------------------------------------------
  82. # Fixtures
  83. # ---------------------------------------------------------------------------
  84. @pytest.fixture
  85. async def slice_test_setup(db_session, tmp_path, monkeypatch):
  86. """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer.
  87. ``base_dir`` is patched via ``monkeypatch`` rather than assigned and
  88. restored by hand. ``app_settings`` is a process-wide singleton, and the
  89. hand-rolled version only restored after the ``yield`` — so anything raising
  90. during setup (a commit, a refresh) left ``base_dir`` pointing at a
  91. ``tmp_path`` that pytest then deleted, and every later test in that xdist
  92. worker which reads it failed. That was the cause of intermittent failures
  93. in ``TestLibraryPathHelpers`` and ``TestArchivePlatesDesignOverrides``,
  94. which share nothing with this module but land in the same worker.
  95. """
  96. storage_dir = tmp_path / "library" / "files"
  97. storage_dir.mkdir(parents=True, exist_ok=True)
  98. src_path = storage_dir / "Cube.stl"
  99. src_path.write_bytes(b"solid Cube\nendsolid\n")
  100. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  101. src_file = LibraryFile(
  102. filename="Cube.stl",
  103. file_path=str(src_path.relative_to(tmp_path)),
  104. file_type="stl",
  105. file_size=src_path.stat().st_size,
  106. )
  107. db_session.add(src_file)
  108. presets = {}
  109. for kind in ("printer", "process", "filament"):
  110. p = LocalPreset(
  111. name=f"Test {kind}",
  112. preset_type=kind,
  113. source="orcaslicer",
  114. setting=json.dumps({"name": f"Test {kind}", "type": kind}),
  115. )
  116. db_session.add(p)
  117. presets[kind] = p
  118. db_session.add(SettingsModel(key="preferred_slicer", value="orcaslicer"))
  119. await db_session.commit()
  120. for p in presets.values():
  121. await db_session.refresh(p)
  122. await db_session.refresh(src_file)
  123. yield {
  124. "src_file_id": src_file.id,
  125. "printer_id": presets["printer"].id,
  126. "process_id": presets["process"].id,
  127. "filament_id": presets["filament"].id,
  128. "tmp_path": tmp_path,
  129. }
  130. slicer_api_module.set_shared_http_client(None)
  131. # ---------------------------------------------------------------------------
  132. # POST /library/files/{id}/slice — synchronous validation paths
  133. # ---------------------------------------------------------------------------
  134. class TestSliceValidation:
  135. @pytest.mark.asyncio
  136. @pytest.mark.integration
  137. async def test_returns_404_when_source_missing(self, async_client: AsyncClient, slice_test_setup):
  138. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  139. response = await async_client.post(
  140. "/api/v1/library/files/999999/slice",
  141. json={
  142. "printer_preset_id": slice_test_setup["printer_id"],
  143. "process_preset_id": slice_test_setup["process_id"],
  144. "filament_preset_id": slice_test_setup["filament_id"],
  145. },
  146. )
  147. assert response.status_code == 404
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_returns_400_for_wrong_file_type(self, async_client: AsyncClient, db_session, slice_test_setup):
  151. gcode_path = slice_test_setup["tmp_path"] / "library" / "files" / "out.gcode"
  152. gcode_path.write_bytes(b"; gcode\n")
  153. gfile = LibraryFile(
  154. filename="out.gcode",
  155. file_path=str(gcode_path.relative_to(slice_test_setup["tmp_path"])),
  156. file_type="gcode",
  157. file_size=10,
  158. )
  159. db_session.add(gfile)
  160. await db_session.commit()
  161. await db_session.refresh(gfile)
  162. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  163. response = await async_client.post(
  164. f"/api/v1/library/files/{gfile.id}/slice",
  165. json={
  166. "printer_preset_id": slice_test_setup["printer_id"],
  167. "process_preset_id": slice_test_setup["process_id"],
  168. "filament_preset_id": slice_test_setup["filament_id"],
  169. },
  170. )
  171. assert response.status_code == 400
  172. assert "STL or 3MF" in response.json()["detail"]
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_step_source_is_refused_with_an_explanation(
  176. self, async_client: AsyncClient, db_session, slice_test_setup
  177. ):
  178. """STEP was accepted here and then failed at the sidecar.
  179. Neither slicer's CLI can load STEP -- it answers "Unknown file format"
  180. and exits 250 -- so the job was read, converted and uploaded only to
  181. come back as "The input model file to the slicer can not be parsed",
  182. which reads as a corrupt model rather than an unsupported format.
  183. """
  184. from backend.app.models.library import LibraryFile
  185. step_path = slice_test_setup["tmp_path"] / "part.step"
  186. step_path.write_bytes(b"ISO-10303-21;\n")
  187. sfile = LibraryFile(
  188. filename="part.step",
  189. file_path=str(step_path.relative_to(slice_test_setup["tmp_path"])),
  190. file_type="step",
  191. file_size=14,
  192. )
  193. db_session.add(sfile)
  194. await db_session.commit()
  195. await db_session.refresh(sfile)
  196. response = await async_client.post(
  197. f"/api/v1/library/files/{sfile.id}/slice",
  198. json={
  199. "printer_preset_id": slice_test_setup["printer_id"],
  200. "process_preset_id": slice_test_setup["process_id"],
  201. "filament_preset_id": slice_test_setup["filament_id"],
  202. },
  203. )
  204. assert response.status_code == 400
  205. detail = response.json()["detail"]
  206. assert "STEP" in detail
  207. # Naming the way out matters more than the refusal.
  208. assert "export" in detail.lower()
  209. # ---------------------------------------------------------------------------
  210. # POST /library/files/{id}/slice — async dispatch + bg job
  211. # ---------------------------------------------------------------------------
  212. class TestSliceLibraryFile:
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_happy_path_returns_202_then_job_completes_with_library_file(
  216. self, async_client: AsyncClient, slice_test_setup
  217. ):
  218. captured: dict = {}
  219. def handler(request: httpx.Request) -> httpx.Response:
  220. captured["url"] = str(request.url)
  221. return httpx.Response(
  222. status_code=200,
  223. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  224. headers={
  225. "x-print-time-seconds": "656",
  226. "x-filament-used-g": "0.94",
  227. "x-filament-used-mm": "302.5",
  228. },
  229. )
  230. _install_mock_sidecar(handler)
  231. response = await async_client.post(
  232. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  233. json={
  234. "printer_preset_id": slice_test_setup["printer_id"],
  235. "process_preset_id": slice_test_setup["process_id"],
  236. "filament_preset_id": slice_test_setup["filament_id"],
  237. },
  238. )
  239. assert response.status_code == 202, response.text
  240. body = response.json()
  241. assert body["status"] == "pending"
  242. assert body["status_url"].startswith("/api/v1/slice-jobs/")
  243. final = await _wait_for_job(async_client, body["job_id"])
  244. assert final["status"] == "completed", final
  245. assert final["result"]["library_file_id"] != slice_test_setup["src_file_id"]
  246. assert final["result"]["print_time_seconds"] == 656
  247. assert captured["url"].endswith("/slice")
  248. @pytest.mark.asyncio
  249. @pytest.mark.integration
  250. async def test_bed_type_override_patches_process_profile(self, async_client: AsyncClient, slice_test_setup):
  251. """#1337: when SliceRequest.bed_type is set, the process JSON sent to
  252. the sidecar must carry curr_bed_type with that exact value. Without
  253. the patch, slicing high-temp filaments on a "Cool Plate" process
  254. preset fails inside the slicer CLI with "does not support filament 1"
  255. and the user has no way to switch plates from the SliceModal."""
  256. captured: dict = {}
  257. def handler(request: httpx.Request) -> httpx.Response:
  258. captured["body"] = bytes(request.content)
  259. return httpx.Response(
  260. status_code=200,
  261. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  262. headers={
  263. "x-print-time-seconds": "10",
  264. "x-filament-used-g": "0.1",
  265. "x-filament-used-mm": "1.0",
  266. },
  267. )
  268. _install_mock_sidecar(handler)
  269. response = await async_client.post(
  270. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  271. json={
  272. "printer_preset_id": slice_test_setup["printer_id"],
  273. "process_preset_id": slice_test_setup["process_id"],
  274. "filament_preset_id": slice_test_setup["filament_id"],
  275. "bed_type": "Textured PEI Plate",
  276. },
  277. )
  278. assert response.status_code == 202
  279. final = await _wait_for_job(async_client, response.json()["job_id"])
  280. assert final["status"] == "completed", final
  281. # The presetProfile part of the multipart upload now carries the
  282. # override. Searching the raw body avoids parsing the multipart by
  283. # hand — the substring is unique enough since we control the JSON
  284. # being patched.
  285. assert b'"curr_bed_type": "Textured PEI Plate"' in captured["body"], (
  286. "bed_type override must appear in the process JSON sent to the sidecar"
  287. )
  288. @pytest.mark.asyncio
  289. @pytest.mark.integration
  290. async def test_bed_type_omitted_leaves_process_profile_untouched(self, async_client: AsyncClient, slice_test_setup):
  291. """Companion to the override test: the patch must NOT fire when the
  292. client omits bed_type, so the process preset's own curr_bed_type
  293. (or absence thereof) is forwarded to the sidecar unchanged."""
  294. captured: dict = {}
  295. def handler(request: httpx.Request) -> httpx.Response:
  296. captured["body"] = bytes(request.content)
  297. return httpx.Response(
  298. status_code=200,
  299. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  300. headers={
  301. "x-print-time-seconds": "10",
  302. "x-filament-used-g": "0.1",
  303. "x-filament-used-mm": "1.0",
  304. },
  305. )
  306. _install_mock_sidecar(handler)
  307. response = await async_client.post(
  308. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  309. json={
  310. "printer_preset_id": slice_test_setup["printer_id"],
  311. "process_preset_id": slice_test_setup["process_id"],
  312. "filament_preset_id": slice_test_setup["filament_id"],
  313. },
  314. )
  315. assert response.status_code == 202
  316. final = await _wait_for_job(async_client, response.json()["job_id"])
  317. assert final["status"] == "completed", final
  318. assert b"curr_bed_type" not in captured["body"], (
  319. "bed_type must stay out of the process JSON when no override is set"
  320. )
  321. @pytest.mark.asyncio
  322. @pytest.mark.integration
  323. async def test_auto_orient_and_arrange_reach_the_sidecar(self, async_client: AsyncClient, slice_test_setup):
  324. """#2548: the two layout passes are per-slice options, so ticking
  325. them in the SliceModal has to come out the other end as the
  326. sidecar's ``orient`` / ``arrange`` form fields. Before this the
  327. flags existed on the wire but only #1493's cross-class detector
  328. could set arrange, and nothing at all could set orient."""
  329. captured: dict = {}
  330. def handler(request: httpx.Request) -> httpx.Response:
  331. captured["body"] = bytes(request.content)
  332. return httpx.Response(
  333. status_code=200,
  334. content=_make_3mf_with_settings(),
  335. headers={
  336. "x-print-time-seconds": "10",
  337. "x-filament-used-g": "0.1",
  338. "x-filament-used-mm": "1.0",
  339. },
  340. )
  341. _install_mock_sidecar(handler)
  342. response = await async_client.post(
  343. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  344. json={
  345. "printer_preset_id": slice_test_setup["printer_id"],
  346. "process_preset_id": slice_test_setup["process_id"],
  347. "filament_preset_id": slice_test_setup["filament_id"],
  348. "auto_orient": True,
  349. "auto_arrange": True,
  350. },
  351. )
  352. assert response.status_code == 202
  353. final = await _wait_for_job(async_client, response.json()["job_id"])
  354. assert final["status"] == "completed", final
  355. assert b'name="orient"' in captured["body"]
  356. assert b'name="arrange"' in captured["body"]
  357. @pytest.mark.asyncio
  358. @pytest.mark.integration
  359. async def test_layout_flags_absent_by_default(self, async_client: AsyncClient, slice_test_setup):
  360. """Companion to the above. Both default to off, and off is expressed
  361. by omitting the field — the sidecar reads any present value as
  362. truthy, so a "false" on the wire would auto-arrange every slice."""
  363. captured: dict = {}
  364. def handler(request: httpx.Request) -> httpx.Response:
  365. captured["body"] = bytes(request.content)
  366. return httpx.Response(
  367. status_code=200,
  368. content=_make_3mf_with_settings(),
  369. headers={
  370. "x-print-time-seconds": "10",
  371. "x-filament-used-g": "0.1",
  372. "x-filament-used-mm": "1.0",
  373. },
  374. )
  375. _install_mock_sidecar(handler)
  376. response = await async_client.post(
  377. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  378. json={
  379. "printer_preset_id": slice_test_setup["printer_id"],
  380. "process_preset_id": slice_test_setup["process_id"],
  381. "filament_preset_id": slice_test_setup["filament_id"],
  382. },
  383. )
  384. assert response.status_code == 202
  385. final = await _wait_for_job(async_client, response.json()["job_id"])
  386. assert final["status"] == "completed", final
  387. assert b'name="orient"' not in captured["body"]
  388. assert b'name="arrange"' not in captured["body"]
  389. @pytest.mark.asyncio
  390. @pytest.mark.integration
  391. async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
  392. self, async_client: AsyncClient, slice_test_setup
  393. ):
  394. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  395. response = await async_client.post(
  396. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  397. json={
  398. # Swap printer/filament — both exist but wrong preset_type.
  399. "printer_preset_id": slice_test_setup["filament_id"],
  400. "process_preset_id": slice_test_setup["process_id"],
  401. "filament_preset_id": slice_test_setup["printer_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"] == 400
  408. assert "preset_type" in (final["error_detail"] or "")
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. @pytest.mark.parametrize(
  412. ("slicer", "source", "as_designed", "reaches_the_slicer"),
  413. [
  414. ("bambu_studio", "stl", False, False),
  415. # The flag does nothing for an STL, so the plate is still applied.
  416. ("bambu_studio", "stl", True, False),
  417. # "Slice as designed" on a 3MF never applies the picked plate.
  418. ("bambu_studio", "3mf", True, True),
  419. ("orcaslicer", "stl", False, True),
  420. ],
  421. )
  422. async def test_an_orca_only_plate_is_refused_for_bambu_studio(
  423. self, async_client: AsyncClient, db_session, slice_test_setup, slicer, source, as_designed, reaches_the_slicer
  424. ):
  425. """Bambu Studio slices a plate name it does not know as Cool Plate (#3359)."""
  426. await db_session.execute(
  427. SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value=slicer)
  428. )
  429. await db_session.commit()
  430. file_id = slice_test_setup["src_file_id"]
  431. if source == "3mf":
  432. path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
  433. path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
  434. threemf = LibraryFile(
  435. filename="designed.3mf",
  436. file_path=str(path.relative_to(slice_test_setup["tmp_path"])),
  437. file_type="3mf",
  438. file_size=path.stat().st_size,
  439. )
  440. db_session.add(threemf)
  441. await db_session.commit()
  442. file_id = threemf.id
  443. sent: list = []
  444. def handler(request: httpx.Request) -> httpx.Response:
  445. sent.append(request)
  446. return httpx.Response(
  447. status_code=200,
  448. content=_make_3mf_with_settings(),
  449. headers={"x-print-time-seconds": "10", "x-filament-used-g": "0.1", "x-filament-used-mm": "1.0"},
  450. )
  451. _install_mock_sidecar(handler)
  452. response = await async_client.post(
  453. f"/api/v1/library/files/{file_id}/slice",
  454. json={
  455. "printer_preset_id": slice_test_setup["printer_id"],
  456. "process_preset_id": slice_test_setup["process_id"],
  457. "filament_preset_id": slice_test_setup["filament_id"],
  458. "bed_type": "Textured Cool Plate",
  459. "use_embedded_settings": as_designed,
  460. },
  461. )
  462. assert response.status_code == 202
  463. final = await _wait_for_job(async_client, response.json()["job_id"])
  464. if reaches_the_slicer:
  465. assert final["status"] == "completed", final
  466. assert any(_is_slice_post(r) for r in sent)
  467. else:
  468. assert final["status"] == "failed"
  469. assert final["error_status"] == 400
  470. assert "Bambu Studio has no Textured Cool Plate" in final["error_detail"]
  471. assert not any(_is_slice_post(r) for r in sent)
  472. @pytest.mark.asyncio
  473. @pytest.mark.integration
  474. async def test_unknown_preferred_slicer_fails_with_400(
  475. self, async_client: AsyncClient, db_session, slice_test_setup
  476. ):
  477. await db_session.execute(
  478. SettingsModel.__table__.update().where(SettingsModel.key == "preferred_slicer").values(value="prusaslicer")
  479. )
  480. await db_session.commit()
  481. _install_mock_sidecar(lambda r: httpx.Response(200, content=b""))
  482. response = await async_client.post(
  483. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  484. json={
  485. "printer_preset_id": slice_test_setup["printer_id"],
  486. "process_preset_id": slice_test_setup["process_id"],
  487. "filament_preset_id": slice_test_setup["filament_id"],
  488. },
  489. )
  490. assert response.status_code == 202
  491. final = await _wait_for_job(async_client, response.json()["job_id"])
  492. assert final["status"] == "failed"
  493. assert final["error_status"] == 400
  494. assert "preferred_slicer" in (final["error_detail"] or "")
  495. @pytest.mark.asyncio
  496. @pytest.mark.integration
  497. async def test_sidecar_unreachable_fails_with_502(self, async_client: AsyncClient, slice_test_setup):
  498. def handler(_: httpx.Request) -> httpx.Response:
  499. raise httpx.ConnectError("connection refused")
  500. _install_mock_sidecar(handler)
  501. response = await async_client.post(
  502. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  503. json={
  504. "printer_preset_id": slice_test_setup["printer_id"],
  505. "process_preset_id": slice_test_setup["process_id"],
  506. "filament_preset_id": slice_test_setup["filament_id"],
  507. },
  508. )
  509. assert response.status_code == 202
  510. final = await _wait_for_job(async_client, response.json()["job_id"])
  511. assert final["status"] == "failed"
  512. assert final["error_status"] == 502
  513. assert "unreachable" in (final["error_detail"] or "").lower()
  514. @pytest.mark.asyncio
  515. @pytest.mark.integration
  516. async def test_3mf_falls_back_to_embedded_settings_on_cli_failure(
  517. self, async_client: AsyncClient, db_session, slice_test_setup
  518. ):
  519. # When the slicer CLI fails on the --load-settings path (segfault
  520. # on complex H2D models), Bambuddy retries with no profile triplet
  521. # so the CLI uses the file's embedded settings.
  522. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex.3mf"
  523. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  524. threemf = LibraryFile(
  525. filename="complex.3mf",
  526. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  527. file_type="3mf",
  528. file_size=src_3mf_path.stat().st_size,
  529. )
  530. db_session.add(threemf)
  531. await db_session.commit()
  532. await db_session.refresh(threemf)
  533. call_count = {"n": 0}
  534. def handler(request: httpx.Request) -> httpx.Response:
  535. if not _is_slice_post(request):
  536. return httpx.Response(404)
  537. call_count["n"] += 1
  538. # First call: profile triplet present → simulate CLI 5xx
  539. if call_count["n"] == 1:
  540. return httpx.Response(
  541. status_code=500,
  542. json={"message": "Failed to slice the model"},
  543. )
  544. # Retry: no profile triplet → succeed with embedded settings
  545. return httpx.Response(
  546. status_code=200,
  547. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  548. headers={
  549. "x-print-time-seconds": "100",
  550. "x-filament-used-g": "1.0",
  551. "x-filament-used-mm": "100",
  552. },
  553. )
  554. _install_mock_sidecar(handler)
  555. response = await async_client.post(
  556. f"/api/v1/library/files/{threemf.id}/slice",
  557. json={
  558. "printer_preset_id": slice_test_setup["printer_id"],
  559. "process_preset_id": slice_test_setup["process_id"],
  560. "filament_preset_id": slice_test_setup["filament_id"],
  561. },
  562. )
  563. assert response.status_code == 202
  564. final = await _wait_for_job(async_client, response.json()["job_id"])
  565. assert final["status"] == "completed", final
  566. assert final["result"]["used_embedded_settings"] is True
  567. assert call_count["n"] == 2 # primary + fallback retry
  568. @pytest.mark.asyncio
  569. @pytest.mark.integration
  570. async def test_stl_does_not_fall_back_on_cli_failure(self, async_client: AsyncClient, slice_test_setup):
  571. # STL has no embedded settings — the CLI 5xx is terminal.
  572. call_count = {"n": 0}
  573. def handler(request: httpx.Request) -> httpx.Response:
  574. if not _is_slice_post(request):
  575. return httpx.Response(404)
  576. call_count["n"] += 1
  577. return httpx.Response(
  578. status_code=500,
  579. json={"message": "Failed to slice the model"},
  580. )
  581. _install_mock_sidecar(handler)
  582. response = await async_client.post(
  583. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  584. json={
  585. "printer_preset_id": slice_test_setup["printer_id"],
  586. "process_preset_id": slice_test_setup["process_id"],
  587. "filament_preset_id": slice_test_setup["filament_id"],
  588. },
  589. )
  590. assert response.status_code == 202
  591. final = await _wait_for_job(async_client, response.json()["job_id"])
  592. assert final["status"] == "failed"
  593. assert final["error_status"] == 502
  594. assert call_count["n"] == 1 # No retry for STL
  595. @pytest.mark.asyncio
  596. @pytest.mark.integration
  597. async def test_3mf_input_forwarded_unmodified_to_sidecar(
  598. self, async_client: AsyncClient, db_session, slice_test_setup
  599. ):
  600. # 3MF input must be forwarded to the sidecar verbatim — every
  601. # Metadata/*.config the source carries (project_settings,
  602. # model_settings, slice_info, cut_information) is needed by the
  603. # CLI to find plate definitions and baseline config; an earlier
  604. # version of this code stripped them and caused the CLI to
  605. # silently exit immediately after "Initializing StaticPrintConfigs"
  606. # for every 3MF slice. --load-settings overrides the specific
  607. # fields the user changed; the rest comes from the embedded data.
  608. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
  609. src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
  610. threemf = LibraryFile(
  611. filename="real.3mf",
  612. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  613. file_type="3mf",
  614. file_size=src_3mf_path.stat().st_size,
  615. )
  616. db_session.add(threemf)
  617. await db_session.commit()
  618. await db_session.refresh(threemf)
  619. captured: dict = {}
  620. def handler(request: httpx.Request) -> httpx.Response:
  621. captured["body"] = request.content
  622. return httpx.Response(
  623. status_code=200,
  624. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  625. headers={
  626. "x-print-time-seconds": "1",
  627. "x-filament-used-g": "0",
  628. "x-filament-used-mm": "0",
  629. },
  630. )
  631. _install_mock_sidecar(handler)
  632. response = await async_client.post(
  633. f"/api/v1/library/files/{threemf.id}/slice",
  634. json={
  635. "printer_preset_id": slice_test_setup["printer_id"],
  636. "process_preset_id": slice_test_setup["process_id"],
  637. "filament_preset_id": slice_test_setup["filament_id"],
  638. },
  639. )
  640. assert response.status_code == 202
  641. final = await _wait_for_job(async_client, response.json()["job_id"])
  642. assert final["status"] == "completed", final
  643. # Recover the embedded zip from the multipart body and assert ALL
  644. # the source's Metadata/*.config files are still present — the
  645. # opposite of the previous (broken) "strip everything" test.
  646. body = captured["body"]
  647. pk = body.find(b"PK\x03\x04")
  648. assert pk >= 0, "3MF body not found in multipart payload"
  649. with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
  650. names = set(zin.namelist())
  651. assert "Metadata/project_settings.config" in names
  652. assert "Metadata/model_settings.config" in names
  653. assert "Metadata/slice_info.config" in names
  654. assert "Metadata/cut_information.xml" in names
  655. assert "3D/3dmodel.model" in names
  656. @pytest.mark.asyncio
  657. @pytest.mark.integration
  658. async def test_use_embedded_settings_skips_profile_triplet(
  659. self, async_client: AsyncClient, db_session, slice_test_setup
  660. ):
  661. # "Slice as designed" (#2611): with use_embedded_settings the 3MF is
  662. # sliced on its own project_settings.config — no --load-settings — so
  663. # the sidecar request carries ONLY the model file, never the
  664. # printer/process/filament profile parts. Succeeds on the first call
  665. # (no crash-fallback), and the result is flagged used_embedded_settings.
  666. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
  667. src_3mf_path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
  668. threemf = LibraryFile(
  669. filename="designed.3mf",
  670. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  671. file_type="3mf",
  672. file_size=src_3mf_path.stat().st_size,
  673. )
  674. db_session.add(threemf)
  675. await db_session.commit()
  676. await db_session.refresh(threemf)
  677. captured: dict = {}
  678. call_count = {"n": 0}
  679. def handler(request: httpx.Request) -> httpx.Response:
  680. if not _is_slice_post(request):
  681. return httpx.Response(404)
  682. call_count["n"] += 1
  683. captured["body"] = request.content
  684. return httpx.Response(
  685. status_code=200,
  686. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  687. headers={
  688. "x-print-time-seconds": "100",
  689. "x-filament-used-g": "1.0",
  690. "x-filament-used-mm": "100",
  691. },
  692. )
  693. _install_mock_sidecar(handler)
  694. response = await async_client.post(
  695. f"/api/v1/library/files/{threemf.id}/slice",
  696. json={
  697. "printer_preset_id": slice_test_setup["printer_id"],
  698. "process_preset_id": slice_test_setup["process_id"],
  699. "filament_preset_id": slice_test_setup["filament_id"],
  700. "use_embedded_settings": True,
  701. },
  702. )
  703. assert response.status_code == 202
  704. final = await _wait_for_job(async_client, response.json()["job_id"])
  705. assert final["status"] == "completed", final
  706. assert final["result"]["used_embedded_settings"] is True
  707. assert call_count["n"] == 1 # embedded path taken directly, no fallback retry
  708. # The multipart body must NOT carry any profile part — that is the
  709. # whole point of the mode. Their presence would mean --load-settings
  710. # ran and overrode the designer's embedded settings.
  711. body = captured["body"]
  712. assert b"printerProfile" not in body
  713. assert b"presetProfile" not in body
  714. assert b"filamentProfile" not in body
  715. @pytest.mark.asyncio
  716. @pytest.mark.integration
  717. async def test_use_embedded_settings_ignored_for_stl(self, async_client: AsyncClient, slice_test_setup):
  718. # An STL has no embedded project settings to honour, so the flag is a
  719. # no-op: the normal profile path runs and the triplet is forwarded.
  720. captured: dict = {}
  721. def handler(request: httpx.Request) -> httpx.Response:
  722. captured["body"] = request.content
  723. return httpx.Response(
  724. status_code=200,
  725. content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
  726. headers={
  727. "x-print-time-seconds": "1",
  728. "x-filament-used-g": "0",
  729. "x-filament-used-mm": "0",
  730. },
  731. )
  732. _install_mock_sidecar(handler)
  733. response = await async_client.post(
  734. f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
  735. json={
  736. "printer_preset_id": slice_test_setup["printer_id"],
  737. "process_preset_id": slice_test_setup["process_id"],
  738. "filament_preset_id": slice_test_setup["filament_id"],
  739. "use_embedded_settings": True,
  740. },
  741. )
  742. assert response.status_code == 202
  743. final = await _wait_for_job(async_client, response.json()["job_id"])
  744. assert final["status"] == "completed", final
  745. assert final["result"]["used_embedded_settings"] is False
  746. assert b"printerProfile" in captured["body"] # profile path still ran
  747. # ---------------------------------------------------------------------------
  748. # GET /slice-jobs/{id}
  749. # ---------------------------------------------------------------------------
  750. class TestSliceJobs:
  751. @pytest.mark.asyncio
  752. @pytest.mark.integration
  753. async def test_unknown_job_returns_404(self, async_client: AsyncClient):
  754. # Sweep dispatcher state so a fresh ID is unknown.
  755. slice_dispatch._jobs.clear()
  756. r = await async_client.get("/api/v1/slice-jobs/999999")
  757. assert r.status_code == 404
  758. # ---------------------------------------------------------------------------
  759. # POST /archives/{id}/slice — re-sliced archive reflects the target printer
  760. # ---------------------------------------------------------------------------
  761. def _make_sliced_3mf(printer_model_id: str, bed_type: str | None = None) -> bytes:
  762. """A minimal sliced-output 3MF that embeds a printer_model_id in
  763. slice_info.config, the way a real Bambu Studio / OrcaSlicer export does.
  764. ThreeMFParser reads this into metadata['sliced_for_model']. When
  765. ``bed_type`` is set, also embed ``curr_bed_type`` so the parser surfaces
  766. ``metadata['bed_type']`` — needed for the bed-type lift assertion in
  767. TestSliceArchiveReslicedBedType."""
  768. extra_meta = f"<metadata key='curr_bed_type' value='{bed_type}'/>" if bed_type else ""
  769. buf = io.BytesIO()
  770. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  771. zf.writestr("3D/3dmodel.model", "<model/>")
  772. zf.writestr(
  773. "Metadata/slice_info.config",
  774. (
  775. "<config><plate>"
  776. f"<metadata key='printer_model_id' value='{printer_model_id}'/>"
  777. f"{extra_meta}"
  778. "</plate></config>"
  779. ),
  780. )
  781. return buf.getvalue()
  782. class TestCrossClassSliceAllLoop:
  783. """#1493: when the user picks "Slice all plates" on a cross-class source
  784. (X1C → H2D), Bambuddy must NOT send a single ``--slice 0 --arrange 1``
  785. call — that consolidates every plate's objects onto one bed via BS's
  786. project-wide arrange. Instead it loops per plate (``plate=N, arrange=true``)
  787. and merges the N single-plate outputs into one multi-plate 3MF locally.
  788. This test mocks the sidecar to assert (a) N calls happen, one per plate,
  789. each with arrange=true, and (b) the resulting archive's stored 3MF
  790. contains plate_1..plate_N.gcode entries."""
  791. @staticmethod
  792. def _make_multi_plate_x1c_source(plate_count: int = 3) -> bytes:
  793. """Source 3MF: X1C-stamped, N plates declared via model_settings."""
  794. plate_blocks = "\n".join(
  795. f'<plate><metadata key="plater_id" value="{i}"/></plate>' for i in range(1, plate_count + 1)
  796. )
  797. buf = io.BytesIO()
  798. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  799. zf.writestr("3D/3dmodel.model", "<model/>")
  800. zf.writestr(
  801. "Metadata/project_settings.config",
  802. json.dumps({"printer_model": "Bambu Lab X1 Carbon"}),
  803. )
  804. zf.writestr(
  805. "Metadata/model_settings.config",
  806. f"<?xml version='1.0'?>\n<config>\n{plate_blocks}\n</config>\n",
  807. )
  808. return buf.getvalue()
  809. @staticmethod
  810. def _make_single_plate_sliced_output(plate_num: int) -> bytes:
  811. """Mock per-plate output: looks like what BS CLI returns for
  812. --slice N. Carries an H2D project_settings (target), a one-line
  813. slice_info <plate> block, and a per-plate gcode + thumbnail."""
  814. buf = io.BytesIO()
  815. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  816. zf.writestr("3D/3dmodel.model", "<model/>")
  817. zf.writestr(
  818. "Metadata/project_settings.config",
  819. json.dumps({"printer_model": "Bambu Lab H2D"}),
  820. )
  821. zf.writestr("Metadata/model_settings.config", "<config/>")
  822. zf.writestr(
  823. "Metadata/slice_info.config",
  824. f"<config><plate><metadata key='index' value='{plate_num}'/>"
  825. f"<metadata key='printer_model_id' value='O1D'/></plate></config>",
  826. )
  827. zf.writestr(f"Metadata/plate_{plate_num}.gcode", f"G{plate_num}".encode())
  828. zf.writestr(f"Metadata/plate_{plate_num}.gcode.md5", b"deadbeef")
  829. zf.writestr(f"Metadata/plate_{plate_num}.json", b"{}")
  830. zf.writestr(f"Metadata/plate_{plate_num}.png", f"P{plate_num}".encode())
  831. return buf.getvalue()
  832. @pytest.mark.asyncio
  833. @pytest.mark.integration
  834. async def test_loops_per_plate_when_cross_class_with_plate_zero(
  835. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  836. ):
  837. from backend.app.models.archive import PrintArchive
  838. tmp_path = slice_test_setup["tmp_path"]
  839. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  840. src_dir = tmp_path / "archives" / "src"
  841. src_dir.mkdir(parents=True, exist_ok=True)
  842. src_3mf = src_dir / "mewtwo.3mf"
  843. src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=3))
  844. printer = await printer_factory()
  845. source = await archive_factory(
  846. printer.id,
  847. filename="mewtwo.3mf",
  848. file_path=str(src_3mf.relative_to(tmp_path)),
  849. sliced_for_model="X1C",
  850. with_run=False,
  851. )
  852. # H2D target preset — the cross-class detector reads the
  853. # ``printer_model`` field off the resolved JSON.
  854. h2d = LocalPreset(
  855. name="# Bambu Lab H2D 0.4 nozzle",
  856. preset_type="printer",
  857. source="orcaslicer",
  858. setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
  859. )
  860. db_session.add(h2d)
  861. await db_session.commit()
  862. await db_session.refresh(h2d)
  863. # Mock sidecar: capture every request and respond with that
  864. # plate's single-plate output. We expect one request per plate
  865. # in the source (3 here).
  866. captured_requests: list[dict] = []
  867. def handler(request: httpx.Request) -> httpx.Response:
  868. if not _is_slice_post(request):
  869. return httpx.Response(404)
  870. # Multipart bodies aren't trivially parseable here; pull
  871. # the plate field by string search since the helper sends
  872. # ``name="plate"`` immediately followed by the value.
  873. body = request.content
  874. plate = None
  875. marker = b'name="plate"\r\n\r\n'
  876. idx = body.find(marker)
  877. if idx != -1:
  878. # Find the next CRLF after the value start.
  879. start = idx + len(marker)
  880. end = body.find(b"\r\n", start)
  881. try:
  882. plate = int(body[start:end].decode("utf-8"))
  883. except (UnicodeDecodeError, ValueError):
  884. plate = None
  885. arrange_in_body = b'name="arrange"' in body
  886. captured_requests.append({"plate": plate, "arrange": arrange_in_body})
  887. return httpx.Response(
  888. status_code=200,
  889. content=self._make_single_plate_sliced_output(plate or 1),
  890. headers={
  891. "x-print-time-seconds": "600",
  892. "x-filament-used-g": "5.0",
  893. "x-filament-used-mm": "1600.0",
  894. },
  895. )
  896. _install_mock_sidecar(handler)
  897. # plate=0 + cross-class triplet → backend should enter the
  898. # per-plate loop, slice each of the 3 plates with arrange=True,
  899. # and merge into one archive.
  900. resp = await async_client.post(
  901. f"/api/v1/archives/{source.id}/slice",
  902. json={
  903. "printer_preset": {"source": "local", "id": str(h2d.id)},
  904. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  905. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  906. "plate": 0,
  907. },
  908. )
  909. assert resp.status_code == 202, resp.text
  910. final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
  911. assert final["status"] == "completed", final
  912. # Exactly one sidecar call per plate, in plate order. The
  913. # ``--arrange 1`` flag travels with every per-plate sub-slice
  914. # (it's what fixes the cross-class boundary error).
  915. plates_called = [c["plate"] for c in captured_requests]
  916. arrange_used = [c["arrange"] for c in captured_requests]
  917. assert plates_called == [1, 2, 3], plates_called
  918. assert all(arrange_used), arrange_used
  919. # The merged archive has plate_1..plate_3.gcode inside its one
  920. # output 3MF (single Bambuddy archive, three plates).
  921. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  922. archive_path = tmp_path / new_archive.file_path
  923. with zipfile.ZipFile(archive_path, "r") as zf:
  924. entries = set(zf.namelist())
  925. assert "Metadata/plate_1.gcode" in entries
  926. assert "Metadata/plate_2.gcode" in entries
  927. assert "Metadata/plate_3.gcode" in entries
  928. # Per-plate-result totals are summed onto the merged archive.
  929. assert new_archive.print_time_seconds == 600 * 3
  930. assert new_archive.filament_used_grams == pytest.approx(5.0 * 3)
  931. @pytest.mark.asyncio
  932. @pytest.mark.integration
  933. async def test_user_requested_arrange_also_loops_per_plate(
  934. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  935. ):
  936. """#2548 inherits #1493's hazard. The per-plate loop exists because
  937. ``--arrange`` is project-wide: a single ``--slice 0 --arrange 1``
  938. collapses every plate's objects onto one bed. That is a property of
  939. the flag, not of the cross-class detour that first needed it — so a
  940. user ticking auto-arrange over "all plates" on a SAME-class source
  941. must take the same loop. Keying the loop on the cross-class decision
  942. alone would send one call and silently return a one-plate result.
  943. """
  944. from backend.app.models.archive import PrintArchive
  945. tmp_path = slice_test_setup["tmp_path"]
  946. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  947. src_dir = tmp_path / "archives" / "src_same_class"
  948. src_dir.mkdir(parents=True, exist_ok=True)
  949. src_3mf = src_dir / "tray.3mf"
  950. src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=2))
  951. printer = await printer_factory()
  952. source = await archive_factory(
  953. printer.id,
  954. filename="tray.3mf",
  955. file_path=str(src_3mf.relative_to(tmp_path)),
  956. sliced_for_model="X1C",
  957. with_run=False,
  958. )
  959. # X1C target: same nozzle class as the X1C source, so #1493's
  960. # detector stays off and only the user's flag is in play.
  961. x1c = LocalPreset(
  962. name="# Bambu Lab X1 Carbon 0.4 nozzle",
  963. preset_type="printer",
  964. source="orcaslicer",
  965. setting=json.dumps({"name": "Bambu Lab X1 Carbon 0.4 nozzle", "printer_model": "Bambu Lab X1 Carbon"}),
  966. )
  967. db_session.add(x1c)
  968. await db_session.commit()
  969. await db_session.refresh(x1c)
  970. captured_requests: list[dict] = []
  971. def handler(request: httpx.Request) -> httpx.Response:
  972. if not _is_slice_post(request):
  973. return httpx.Response(404)
  974. body = request.content
  975. plate = None
  976. marker = b'name="plate"\r\n\r\n'
  977. idx = body.find(marker)
  978. if idx != -1:
  979. start = idx + len(marker)
  980. end = body.find(b"\r\n", start)
  981. try:
  982. plate = int(body[start:end].decode("utf-8"))
  983. except (UnicodeDecodeError, ValueError):
  984. plate = None
  985. captured_requests.append(
  986. {
  987. "plate": plate,
  988. "arrange": b'name="arrange"' in body,
  989. "orient": b'name="orient"' in body,
  990. }
  991. )
  992. return httpx.Response(
  993. status_code=200,
  994. content=self._make_single_plate_sliced_output(plate or 1),
  995. headers={
  996. "x-print-time-seconds": "300",
  997. "x-filament-used-g": "2.0",
  998. "x-filament-used-mm": "800.0",
  999. },
  1000. )
  1001. _install_mock_sidecar(handler)
  1002. resp = await async_client.post(
  1003. f"/api/v1/archives/{source.id}/slice",
  1004. json={
  1005. "printer_preset": {"source": "local", "id": str(x1c.id)},
  1006. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  1007. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  1008. "plate": 0,
  1009. "auto_arrange": True,
  1010. "auto_orient": True,
  1011. },
  1012. )
  1013. assert resp.status_code == 202, resp.text
  1014. final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
  1015. assert final["status"] == "completed", final
  1016. assert [c["plate"] for c in captured_requests] == [1, 2]
  1017. assert all(c["arrange"] for c in captured_requests)
  1018. # Orient rides along on every sub-slice too — it is per-object, so
  1019. # dropping it on the loop path would quietly ignore the user's tick.
  1020. assert all(c["orient"] for c in captured_requests)
  1021. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1022. with zipfile.ZipFile(tmp_path / new_archive.file_path, "r") as zf:
  1023. entries = set(zf.namelist())
  1024. assert "Metadata/plate_1.gcode" in entries
  1025. assert "Metadata/plate_2.gcode" in entries
  1026. @pytest.mark.asyncio
  1027. @pytest.mark.integration
  1028. async def test_embedded_settings_slice_all_with_arrange_still_loops(
  1029. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1030. ):
  1031. """ "Slice as designed" must not skip the loop. The project-wide
  1032. collapse comes from ``--arrange``; where the print config came from
  1033. has no bearing on it. Taking the single-call embedded branch here
  1034. would return one consolidated plate for a job the user asked to
  1035. slice as N — and the per-plate calls must still omit the profile
  1036. triplet, or "as designed" would silently stop meaning that.
  1037. """
  1038. from backend.app.models.archive import PrintArchive
  1039. tmp_path = slice_test_setup["tmp_path"]
  1040. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1041. src_dir = tmp_path / "archives" / "src_embedded"
  1042. src_dir.mkdir(parents=True, exist_ok=True)
  1043. src_3mf = src_dir / "kit.3mf"
  1044. src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=2))
  1045. printer = await printer_factory()
  1046. source = await archive_factory(
  1047. printer.id,
  1048. filename="kit.3mf",
  1049. file_path=str(src_3mf.relative_to(tmp_path)),
  1050. sliced_for_model="X1C",
  1051. with_run=False,
  1052. )
  1053. x1c = LocalPreset(
  1054. name="# Bambu Lab X1 Carbon 0.4 nozzle",
  1055. preset_type="printer",
  1056. source="orcaslicer",
  1057. setting=json.dumps({"name": "Bambu Lab X1 Carbon 0.4 nozzle", "printer_model": "Bambu Lab X1 Carbon"}),
  1058. )
  1059. db_session.add(x1c)
  1060. await db_session.commit()
  1061. await db_session.refresh(x1c)
  1062. captured_requests: list[dict] = []
  1063. def handler(request: httpx.Request) -> httpx.Response:
  1064. if not _is_slice_post(request):
  1065. return httpx.Response(404)
  1066. body = request.content
  1067. plate = None
  1068. marker = b'name="plate"\r\n\r\n'
  1069. idx = body.find(marker)
  1070. if idx != -1:
  1071. start = idx + len(marker)
  1072. end = body.find(b"\r\n", start)
  1073. try:
  1074. plate = int(body[start:end].decode("utf-8"))
  1075. except (UnicodeDecodeError, ValueError):
  1076. plate = None
  1077. captured_requests.append(
  1078. {
  1079. "plate": plate,
  1080. "arrange": b'name="arrange"' in body,
  1081. "has_profiles": b'name="printerProfile"' in body,
  1082. }
  1083. )
  1084. return httpx.Response(
  1085. status_code=200,
  1086. content=self._make_single_plate_sliced_output(plate or 1),
  1087. headers={
  1088. "x-print-time-seconds": "300",
  1089. "x-filament-used-g": "2.0",
  1090. "x-filament-used-mm": "800.0",
  1091. },
  1092. )
  1093. _install_mock_sidecar(handler)
  1094. resp = await async_client.post(
  1095. f"/api/v1/archives/{source.id}/slice",
  1096. json={
  1097. "printer_preset": {"source": "local", "id": str(x1c.id)},
  1098. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  1099. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  1100. "plate": 0,
  1101. "use_embedded_settings": True,
  1102. "auto_arrange": True,
  1103. },
  1104. )
  1105. assert resp.status_code == 202, resp.text
  1106. final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
  1107. assert final["status"] == "completed", final
  1108. assert [c["plate"] for c in captured_requests] == [1, 2]
  1109. assert all(c["arrange"] for c in captured_requests)
  1110. # No --load-settings on any sub-call: the file's own settings drive
  1111. # each plate, which is what "slice as designed" promises.
  1112. assert not any(c["has_profiles"] for c in captured_requests)
  1113. assert final["result"]["used_embedded_settings"] is True
  1114. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1115. with zipfile.ZipFile(tmp_path / new_archive.file_path, "r") as zf:
  1116. entries = set(zf.namelist())
  1117. assert "Metadata/plate_1.gcode" in entries
  1118. assert "Metadata/plate_2.gcode" in entries
  1119. @pytest.mark.asyncio
  1120. @pytest.mark.integration
  1121. async def test_cross_class_arrange_survives_user_leaving_the_box_unticked(
  1122. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1123. ):
  1124. """The user's per-slice choice is a union with #1493's decision, not
  1125. a replacement for it. Arrange is what keeps a class-crossing slice
  1126. from landing in the target's dead zone or segfaulting ZFiller — so
  1127. the default-false ``auto_arrange`` must not be able to turn it off.
  1128. """
  1129. tmp_path = slice_test_setup["tmp_path"]
  1130. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1131. src_dir = tmp_path / "archives" / "src_cross_single"
  1132. src_dir.mkdir(parents=True, exist_ok=True)
  1133. src_3mf = src_dir / "clip.3mf"
  1134. src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=1))
  1135. printer = await printer_factory()
  1136. source = await archive_factory(
  1137. printer.id,
  1138. filename="clip.3mf",
  1139. file_path=str(src_3mf.relative_to(tmp_path)),
  1140. sliced_for_model="X1C",
  1141. with_run=False,
  1142. )
  1143. h2d = LocalPreset(
  1144. name="# Bambu Lab H2D 0.4 nozzle",
  1145. preset_type="printer",
  1146. source="orcaslicer",
  1147. setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
  1148. )
  1149. db_session.add(h2d)
  1150. await db_session.commit()
  1151. await db_session.refresh(h2d)
  1152. captured: dict = {}
  1153. def handler(request: httpx.Request) -> httpx.Response:
  1154. if not _is_slice_post(request):
  1155. return httpx.Response(404)
  1156. captured["body"] = bytes(request.content)
  1157. return httpx.Response(
  1158. status_code=200,
  1159. content=self._make_single_plate_sliced_output(1),
  1160. headers={
  1161. "x-print-time-seconds": "300",
  1162. "x-filament-used-g": "2.0",
  1163. "x-filament-used-mm": "800.0",
  1164. },
  1165. )
  1166. _install_mock_sidecar(handler)
  1167. resp = await async_client.post(
  1168. f"/api/v1/archives/{source.id}/slice",
  1169. json={
  1170. "printer_preset": {"source": "local", "id": str(h2d.id)},
  1171. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  1172. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  1173. "plate": 1,
  1174. "auto_arrange": False,
  1175. },
  1176. )
  1177. assert resp.status_code == 202, resp.text
  1178. final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
  1179. assert final["status"] == "completed", final
  1180. assert b'name="arrange"' in captured["body"], "cross-class arrange must survive an explicit auto_arrange=false"
  1181. class TestSliceArchiveResliceModel:
  1182. """Re-slicing an archive for a different printer must stamp the new
  1183. archive with the printer it was sliced FOR, not the source's printer."""
  1184. @pytest.mark.asyncio
  1185. @pytest.mark.integration
  1186. async def test_reslice_uses_target_model_not_source_model(
  1187. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1188. ):
  1189. from backend.app.models.archive import PrintArchive
  1190. tmp_path = slice_test_setup["tmp_path"]
  1191. # archive_dir is a static path off the real data dir; point it under
  1192. # base_dir (= tmp_path) so the new archive's file resolves there.
  1193. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1194. # Source archive: a 3MF that was sliced for an X1C.
  1195. src_dir = tmp_path / "archives" / "src"
  1196. src_dir.mkdir(parents=True, exist_ok=True)
  1197. src_3mf = src_dir / "cube.3mf"
  1198. src_3mf.write_bytes(_make_3mf_with_settings())
  1199. printer = await printer_factory()
  1200. source = await archive_factory(
  1201. printer.id,
  1202. filename="cube.3mf",
  1203. file_path=str(src_3mf.relative_to(tmp_path)),
  1204. sliced_for_model="X1C",
  1205. with_run=False,
  1206. )
  1207. source_id = source.id
  1208. # The slicer returns a 3MF whose embedded printer_model_id is O1D (H2D).
  1209. def handler(request: httpx.Request) -> httpx.Response:
  1210. return httpx.Response(
  1211. status_code=200,
  1212. content=_make_sliced_3mf("O1D"),
  1213. headers={
  1214. "x-print-time-seconds": "600",
  1215. "x-filament-used-g": "5.0",
  1216. "x-filament-used-mm": "1600.0",
  1217. },
  1218. )
  1219. _install_mock_sidecar(handler)
  1220. resp = await async_client.post(
  1221. f"/api/v1/archives/{source_id}/slice",
  1222. json={
  1223. "printer_preset_id": slice_test_setup["printer_id"],
  1224. "process_preset_id": slice_test_setup["process_id"],
  1225. "filament_preset_id": slice_test_setup["filament_id"],
  1226. },
  1227. )
  1228. assert resp.status_code == 202, resp.text
  1229. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1230. assert final["status"] == "completed", final
  1231. new_id = final["result"]["archive_id"]
  1232. assert new_id != source_id
  1233. new_archive = await db_session.get(PrintArchive, new_id)
  1234. # The fix: the re-sliced archive reflects H2D — the printer it was
  1235. # sliced for — instead of inheriting X1C from the source archive.
  1236. assert new_archive.sliced_for_model == "H2D"
  1237. # Source archive is untouched.
  1238. source_reloaded = await db_session.get(PrintArchive, source_id)
  1239. assert source_reloaded.sliced_for_model == "X1C"
  1240. @pytest.mark.asyncio
  1241. @pytest.mark.integration
  1242. async def test_cross_model_reslice_drops_source_printer_id(
  1243. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1244. ):
  1245. """A cross-model re-slice (source's X1C → target's H2D) must not carry
  1246. over ``source.printer_id``. The archive card and reprint modal both
  1247. read ``printer_id`` first and only fall back to ``sliced_for_model``
  1248. when it's None, so leaving the inherited id makes the H2D-sliced card
  1249. display the source's X1C printer name (the "Workshop H2C" bug)."""
  1250. from backend.app.models.archive import PrintArchive
  1251. tmp_path = slice_test_setup["tmp_path"]
  1252. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1253. src_dir = tmp_path / "archives" / "src"
  1254. src_dir.mkdir(parents=True, exist_ok=True)
  1255. src_3mf = src_dir / "cube.3mf"
  1256. src_3mf.write_bytes(_make_3mf_with_settings())
  1257. source_printer = await printer_factory()
  1258. source = await archive_factory(
  1259. source_printer.id,
  1260. filename="cube.3mf",
  1261. file_path=str(src_3mf.relative_to(tmp_path)),
  1262. sliced_for_model="X1C",
  1263. with_run=False,
  1264. )
  1265. source_id = source.id
  1266. source_printer_id = source_printer.id
  1267. def handler(request: httpx.Request) -> httpx.Response:
  1268. return httpx.Response(
  1269. status_code=200,
  1270. content=_make_sliced_3mf("O1D"), # H2D
  1271. headers={
  1272. "x-print-time-seconds": "600",
  1273. "x-filament-used-g": "5.0",
  1274. "x-filament-used-mm": "1600.0",
  1275. },
  1276. )
  1277. _install_mock_sidecar(handler)
  1278. resp = await async_client.post(
  1279. f"/api/v1/archives/{source_id}/slice",
  1280. json={
  1281. "printer_preset_id": slice_test_setup["printer_id"],
  1282. "process_preset_id": slice_test_setup["process_id"],
  1283. "filament_preset_id": slice_test_setup["filament_id"],
  1284. },
  1285. )
  1286. assert resp.status_code == 202, resp.text
  1287. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1288. assert final["status"] == "completed", final
  1289. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1290. assert new_archive.sliced_for_model == "H2D"
  1291. # Card / reprint modal will now fall back to the sliced_for_model
  1292. # badge instead of showing the source printer's name.
  1293. assert new_archive.printer_id is None
  1294. # Source untouched: still bound to its original printer.
  1295. source_reloaded = await db_session.get(PrintArchive, source_id)
  1296. assert source_reloaded.printer_id == source_printer_id
  1297. @pytest.mark.asyncio
  1298. @pytest.mark.integration
  1299. async def test_same_model_reslice_preserves_source_printer_id(
  1300. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1301. ):
  1302. """Same-model re-slice (X1C → X1C, e.g. just swapped a process preset)
  1303. keeps ``printer_id`` so the reprint modal pre-selects the original
  1304. printer. Only cross-model re-slices null it out."""
  1305. from backend.app.models.archive import PrintArchive
  1306. tmp_path = slice_test_setup["tmp_path"]
  1307. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1308. src_dir = tmp_path / "archives" / "src"
  1309. src_dir.mkdir(parents=True, exist_ok=True)
  1310. src_3mf = src_dir / "cube.3mf"
  1311. src_3mf.write_bytes(_make_3mf_with_settings())
  1312. source_printer = await printer_factory()
  1313. source = await archive_factory(
  1314. source_printer.id,
  1315. filename="cube.3mf",
  1316. file_path=str(src_3mf.relative_to(tmp_path)),
  1317. sliced_for_model="X1C",
  1318. with_run=False,
  1319. )
  1320. def handler(request: httpx.Request) -> httpx.Response:
  1321. return httpx.Response(
  1322. status_code=200,
  1323. content=_make_sliced_3mf("BL-P001"), # X1C — same model as source
  1324. headers={
  1325. "x-print-time-seconds": "600",
  1326. "x-filament-used-g": "5.0",
  1327. "x-filament-used-mm": "1600.0",
  1328. },
  1329. )
  1330. _install_mock_sidecar(handler)
  1331. resp = await async_client.post(
  1332. f"/api/v1/archives/{source.id}/slice",
  1333. json={
  1334. "printer_preset_id": slice_test_setup["printer_id"],
  1335. "process_preset_id": slice_test_setup["process_id"],
  1336. "filament_preset_id": slice_test_setup["filament_id"],
  1337. },
  1338. )
  1339. assert resp.status_code == 202, resp.text
  1340. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1341. assert final["status"] == "completed", final
  1342. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1343. assert new_archive.sliced_for_model == "X1C"
  1344. # Same-model: keep the source's printer assignment so reprint pre-selects it.
  1345. assert new_archive.printer_id == source_printer.id
  1346. @pytest.mark.asyncio
  1347. @pytest.mark.integration
  1348. async def test_reslice_with_unknown_source_model_preserves_printer_id(
  1349. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1350. ):
  1351. """When ``source.sliced_for_model`` is None (older archive that
  1352. predates that column being populated), the backend can't tell whether
  1353. this is a cross-model re-slice. Fail open and preserve ``printer_id``
  1354. rather than spuriously nulling it — current pre-fix behaviour, kept
  1355. as a deliberate edge case."""
  1356. from backend.app.models.archive import PrintArchive
  1357. tmp_path = slice_test_setup["tmp_path"]
  1358. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1359. src_dir = tmp_path / "archives" / "src"
  1360. src_dir.mkdir(parents=True, exist_ok=True)
  1361. src_3mf = src_dir / "cube.3mf"
  1362. src_3mf.write_bytes(_make_3mf_with_settings())
  1363. source_printer = await printer_factory()
  1364. source = await archive_factory(
  1365. source_printer.id,
  1366. filename="cube.3mf",
  1367. file_path=str(src_3mf.relative_to(tmp_path)),
  1368. sliced_for_model=None,
  1369. with_run=False,
  1370. )
  1371. def handler(request: httpx.Request) -> httpx.Response:
  1372. return httpx.Response(
  1373. status_code=200,
  1374. content=_make_sliced_3mf("O1D"),
  1375. headers={
  1376. "x-print-time-seconds": "600",
  1377. "x-filament-used-g": "5.0",
  1378. "x-filament-used-mm": "1600.0",
  1379. },
  1380. )
  1381. _install_mock_sidecar(handler)
  1382. resp = await async_client.post(
  1383. f"/api/v1/archives/{source.id}/slice",
  1384. json={
  1385. "printer_preset_id": slice_test_setup["printer_id"],
  1386. "process_preset_id": slice_test_setup["process_id"],
  1387. "filament_preset_id": slice_test_setup["filament_id"],
  1388. },
  1389. )
  1390. assert resp.status_code == 202, resp.text
  1391. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1392. assert final["status"] == "completed", final
  1393. new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1394. # Insufficient info to decide cross-model → preserve printer_id.
  1395. assert new_archive.printer_id == source_printer.id
  1396. class TestSliceArchiveReslicedThumbnail:
  1397. """#1493 follow-up: the re-sliced archive's cover image preference order is
  1398. source's per-plate render > sliced output's per-plate render >
  1399. Auxiliaries marketing thumbnail. BS CLI rarely writes a fresh
  1400. ``Metadata/plate_N.png`` on the sliced output, so the source's render
  1401. of the same plate (closer to what's actually printing) wins over the
  1402. project-wide marketing image."""
  1403. @staticmethod
  1404. def _make_source_with_plate_png(plate_png_bytes: bytes) -> bytes:
  1405. buf = io.BytesIO()
  1406. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1407. zf.writestr("3D/3dmodel.model", "<model/>")
  1408. zf.writestr("Metadata/plate_1.png", plate_png_bytes)
  1409. # Project-wide marketing image — the unwanted fallback target.
  1410. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART")
  1411. return buf.getvalue()
  1412. @pytest.mark.asyncio
  1413. @pytest.mark.integration
  1414. async def test_uses_source_plate_png_when_sliced_output_lacks_one(
  1415. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1416. ):
  1417. """Sliced output has no per-plate PNG (typical of BS CLI output
  1418. with --arrange). The source's plate_1.png must win over the
  1419. sliced output's Auxiliaries fallback."""
  1420. from backend.app.models.archive import PrintArchive
  1421. tmp_path = slice_test_setup["tmp_path"]
  1422. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1423. # Source has its own plate_1.png AND a project-wide cover.
  1424. source_plate_marker = b"SOURCE_PLATE_RENDER"
  1425. src_dir = tmp_path / "archives" / "src"
  1426. src_dir.mkdir(parents=True, exist_ok=True)
  1427. src_3mf = src_dir / "cube.3mf"
  1428. src_3mf.write_bytes(self._make_source_with_plate_png(source_plate_marker))
  1429. printer = await printer_factory()
  1430. source = await archive_factory(
  1431. printer.id,
  1432. filename="cube.3mf",
  1433. file_path=str(src_3mf.relative_to(tmp_path)),
  1434. sliced_for_model="X1C",
  1435. with_run=False,
  1436. )
  1437. # Mock slicer returns a 3MF with NO Metadata/plate_1.png — only
  1438. # the Auxiliaries cover, mimicking BS CLI output with --arrange.
  1439. def handler(request: httpx.Request) -> httpx.Response:
  1440. sliced_buf = io.BytesIO()
  1441. with zipfile.ZipFile(sliced_buf, "w") as zf:
  1442. zf.writestr("3D/3dmodel.model", "<model/>")
  1443. zf.writestr("Metadata/slice_info.config", "<config/>")
  1444. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"SLICED_COVER_ART")
  1445. return httpx.Response(
  1446. status_code=200,
  1447. content=sliced_buf.getvalue(),
  1448. headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"},
  1449. )
  1450. _install_mock_sidecar(handler)
  1451. resp = await async_client.post(
  1452. f"/api/v1/archives/{source.id}/slice",
  1453. json={
  1454. "printer_preset_id": slice_test_setup["printer_id"],
  1455. "process_preset_id": slice_test_setup["process_id"],
  1456. "filament_preset_id": slice_test_setup["filament_id"],
  1457. },
  1458. )
  1459. assert resp.status_code == 202, resp.text
  1460. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1461. assert final["status"] == "completed", final
  1462. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1463. assert new.thumbnail_path is not None
  1464. thumb_full = tmp_path / new.thumbnail_path
  1465. assert thumb_full.read_bytes() == source_plate_marker, (
  1466. "Re-sliced archive's thumbnail should be the source's per-plate render, not the Auxiliaries cover art."
  1467. )
  1468. @pytest.mark.asyncio
  1469. @pytest.mark.integration
  1470. async def test_falls_back_to_auxiliaries_when_source_lacks_plate_png(
  1471. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1472. ):
  1473. """When the source has no per-plate render (unsliced library upload),
  1474. the Auxiliaries marketing image from the sliced output is the
  1475. next-best preview — better than no card thumbnail at all."""
  1476. from backend.app.models.archive import PrintArchive
  1477. tmp_path = slice_test_setup["tmp_path"]
  1478. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1479. # Source has no Metadata/plate_1.png at all.
  1480. bare_buf = io.BytesIO()
  1481. with zipfile.ZipFile(bare_buf, "w") as zf:
  1482. zf.writestr("3D/3dmodel.model", "<model/>")
  1483. src_dir = tmp_path / "archives" / "src"
  1484. src_dir.mkdir(parents=True, exist_ok=True)
  1485. src_3mf = src_dir / "bare.3mf"
  1486. src_3mf.write_bytes(bare_buf.getvalue())
  1487. printer = await printer_factory()
  1488. source = await archive_factory(
  1489. printer.id,
  1490. filename="bare.3mf",
  1491. file_path=str(src_3mf.relative_to(tmp_path)),
  1492. sliced_for_model="X1C",
  1493. with_run=False,
  1494. )
  1495. def handler(request: httpx.Request) -> httpx.Response:
  1496. sliced_buf = io.BytesIO()
  1497. with zipfile.ZipFile(sliced_buf, "w") as zf:
  1498. zf.writestr("3D/3dmodel.model", "<model/>")
  1499. zf.writestr("Metadata/slice_info.config", "<config/>")
  1500. zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART_FALLBACK")
  1501. return httpx.Response(
  1502. status_code=200,
  1503. content=sliced_buf.getvalue(),
  1504. headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"},
  1505. )
  1506. _install_mock_sidecar(handler)
  1507. resp = await async_client.post(
  1508. f"/api/v1/archives/{source.id}/slice",
  1509. json={
  1510. "printer_preset_id": slice_test_setup["printer_id"],
  1511. "process_preset_id": slice_test_setup["process_id"],
  1512. "filament_preset_id": slice_test_setup["filament_id"],
  1513. },
  1514. )
  1515. assert resp.status_code == 202, resp.text
  1516. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1517. assert final["status"] == "completed", final
  1518. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1519. assert new.thumbnail_path is not None
  1520. thumb_full = tmp_path / new.thumbnail_path
  1521. assert thumb_full.read_bytes() == b"COVER_ART_FALLBACK"
  1522. class TestSliceArchiveReslicedBedType:
  1523. """#1493 follow-up: the re-sliced archive's ``bed_type`` column must be
  1524. set from the produced 3MF's ``curr_bed_type`` so the frontend's archive
  1525. card shows the right build-plate badge (the card reads the column, not
  1526. extra_data, so the value was previously invisible after a re-slice)."""
  1527. @pytest.mark.asyncio
  1528. @pytest.mark.integration
  1529. async def test_bed_type_lifted_from_sliced_output(
  1530. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1531. ):
  1532. from backend.app.models.archive import PrintArchive
  1533. tmp_path = slice_test_setup["tmp_path"]
  1534. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1535. src_dir = tmp_path / "archives" / "src"
  1536. src_dir.mkdir(parents=True, exist_ok=True)
  1537. src_3mf = src_dir / "cube.3mf"
  1538. src_3mf.write_bytes(_make_3mf_with_settings())
  1539. printer = await printer_factory()
  1540. source = await archive_factory(
  1541. printer.id,
  1542. filename="cube.3mf",
  1543. file_path=str(src_3mf.relative_to(tmp_path)),
  1544. sliced_for_model="X1C",
  1545. bed_type="Cool Plate",
  1546. with_run=False,
  1547. )
  1548. # Mock slicer: produced 3MF declares a different plate type than
  1549. # the source archive's ``Cool Plate``. The new column must reflect
  1550. # the slicer's value (the user picked a different plate in the
  1551. # SliceModal) instead of inheriting the source's.
  1552. def handler(request: httpx.Request) -> httpx.Response:
  1553. return httpx.Response(
  1554. status_code=200,
  1555. content=_make_sliced_3mf("O1D", bed_type="Textured PEI Plate"),
  1556. headers={
  1557. "x-print-time-seconds": "600",
  1558. "x-filament-used-g": "5.0",
  1559. "x-filament-used-mm": "1600.0",
  1560. },
  1561. )
  1562. _install_mock_sidecar(handler)
  1563. resp = await async_client.post(
  1564. f"/api/v1/archives/{source.id}/slice",
  1565. json={
  1566. "printer_preset_id": slice_test_setup["printer_id"],
  1567. "process_preset_id": slice_test_setup["process_id"],
  1568. "filament_preset_id": slice_test_setup["filament_id"],
  1569. },
  1570. )
  1571. assert resp.status_code == 202, resp.text
  1572. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1573. assert final["status"] == "completed", final
  1574. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1575. assert new.bed_type == "Textured PEI Plate"
  1576. @pytest.mark.asyncio
  1577. @pytest.mark.integration
  1578. async def test_bed_type_falls_back_to_source_when_missing_from_output(
  1579. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1580. ):
  1581. """An older sidecar or sparse slice profile may produce a 3MF without
  1582. ``curr_bed_type``. The source archive's ``bed_type`` is the right
  1583. default in that case — better than leaving the badge blank."""
  1584. from backend.app.models.archive import PrintArchive
  1585. tmp_path = slice_test_setup["tmp_path"]
  1586. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1587. src_dir = tmp_path / "archives" / "src"
  1588. src_dir.mkdir(parents=True, exist_ok=True)
  1589. src_3mf = src_dir / "cube.3mf"
  1590. src_3mf.write_bytes(_make_3mf_with_settings())
  1591. printer = await printer_factory()
  1592. source = await archive_factory(
  1593. printer.id,
  1594. filename="cube.3mf",
  1595. file_path=str(src_3mf.relative_to(tmp_path)),
  1596. sliced_for_model="X1C",
  1597. bed_type="Cool Plate",
  1598. with_run=False,
  1599. )
  1600. def handler(request: httpx.Request) -> httpx.Response:
  1601. return httpx.Response(
  1602. status_code=200,
  1603. # No bed_type embedded — simulates a sidecar that drops it.
  1604. content=_make_sliced_3mf("O1D"),
  1605. headers={
  1606. "x-print-time-seconds": "600",
  1607. "x-filament-used-g": "5.0",
  1608. "x-filament-used-mm": "1600.0",
  1609. },
  1610. )
  1611. _install_mock_sidecar(handler)
  1612. resp = await async_client.post(
  1613. f"/api/v1/archives/{source.id}/slice",
  1614. json={
  1615. "printer_preset_id": slice_test_setup["printer_id"],
  1616. "process_preset_id": slice_test_setup["process_id"],
  1617. "filament_preset_id": slice_test_setup["filament_id"],
  1618. },
  1619. )
  1620. assert resp.status_code == 202, resp.text
  1621. final = await _wait_for_job(async_client, resp.json()["job_id"])
  1622. assert final["status"] == "completed", final
  1623. new = await db_session.get(PrintArchive, final["result"]["archive_id"])
  1624. assert new.bed_type == "Cool Plate"
  1625. # ---------------------------------------------------------------------------
  1626. # Slicer content rejections surface instead of silently falling back
  1627. # ---------------------------------------------------------------------------
  1628. class TestSlicerRejectionMessage:
  1629. """_slicer_rejection_message distinguishes a real slicer content rejection
  1630. (surface it to the user) from a CLI crash (fall back to embedded)."""
  1631. def test_extracts_bed_boundary_reason(self):
  1632. text = (
  1633. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1634. "Some objects are located over the boundary of the heated bed.: "
  1635. "Slicer process failed (exit code 204)\nstdout: trace ..."
  1636. )
  1637. assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
  1638. def test_extracts_filament_temp_reason(self):
  1639. text = (
  1640. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1641. "The temperature difference of the filaments used is too large.: "
  1642. "Slicer process failed (exit code 194)"
  1643. )
  1644. assert _slicer_rejection_message(text) == "The temperature difference of the filaments used is too large."
  1645. def test_generic_cli_failure_is_not_a_rejection(self):
  1646. # The #1201 CLI-crash signature carries no slicer error_string, so it
  1647. # must still fall through to the embedded-settings fallback.
  1648. assert _slicer_rejection_message("Slicer CLI failed (500): Failed to slice the model") is None
  1649. def test_empty_or_unrelated_text(self):
  1650. assert _slicer_rejection_message("") is None
  1651. assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
  1652. def test_replaces_input_preset_invalid_placeholder_with_cli_error_line(self):
  1653. # #1851: the CLI emits its catch-all "input preset file is invalid"
  1654. # placeholder for every -5 exit, including real preset-vs-printer
  1655. # compatibility rejections. The actual diagnostic only appears in the
  1656. # stdout `[error] run NNNN:` line; the function must prefer that.
  1657. text = (
  1658. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1659. "The input preset file is invalid and can not be parsed.: "
  1660. "Slicer process failed (exit code 251)\n"
  1661. "stdout: [2026-06-29 04:12:11.952784] [trace] Initializing StaticPrintConfigs\n"
  1662. "[2026-06-29 04:12:12.175810] [error] run 3008: filament preset "
  1663. "Generic PLA @BBL H2C (slot 1) is not compatible with printer "
  1664. "Bambu Lab A1 0.4 nozzle.\n"
  1665. "run found error, return -5, exit..."
  1666. )
  1667. assert (
  1668. _slicer_rejection_message(text) == "filament preset Generic PLA @BBL H2C (slot 1) is not compatible with "
  1669. "printer Bambu Lab A1 0.4 nozzle."
  1670. )
  1671. def test_keeps_meaningful_reason_even_when_cli_error_line_present(self):
  1672. # When the headline error_string is already a useful reason (here:
  1673. # the bed-boundary rejection), don't override it with a generic
  1674. # `[error]` line that may just be the same message restated. Avoids
  1675. # double-text duplication in the user-facing detail.
  1676. text = (
  1677. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1678. "Some objects are located over the boundary of the heated bed.: "
  1679. "Slicer process failed (exit code 204)\n"
  1680. "stdout: [error] some unrelated stdout chatter"
  1681. )
  1682. assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
  1683. def test_cli_error_line_without_run_prefix(self):
  1684. # The CLI sometimes logs `[error] <msg>` without the `run NNNN:`
  1685. # prefix (different code paths). The regex must still pick it up.
  1686. text = (
  1687. "Slicer CLI failed (500): Slicing failed with error from slicer: "
  1688. "The input preset file is invalid and can not be parsed.: "
  1689. "Slicer process failed (exit code 251)\n"
  1690. "stdout: [2026-06-29 12:00:00.000000] [error] Configuration parse failed: missing key 'printer_settings_id'"
  1691. )
  1692. assert _slicer_rejection_message(text) == "Configuration parse failed: missing key 'printer_settings_id'"
  1693. class TestSliceSlicerRejection:
  1694. @pytest.mark.asyncio
  1695. @pytest.mark.integration
  1696. async def test_3mf_surfaces_slicer_rejection_instead_of_falling_back(
  1697. self, async_client: AsyncClient, db_session, slice_test_setup
  1698. ):
  1699. """A real slicer content rejection (e.g. re-slicing for a printer with
  1700. a smaller bed) must surface as a 400 — not silently fall back to the
  1701. source 3MF's embedded settings, which would re-slice for the original
  1702. printer and hide the problem."""
  1703. src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "toobig.3mf"
  1704. src_3mf_path.write_bytes(_make_3mf_with_settings())
  1705. threemf = LibraryFile(
  1706. filename="toobig.3mf",
  1707. file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
  1708. file_type="3mf",
  1709. file_size=src_3mf_path.stat().st_size,
  1710. )
  1711. db_session.add(threemf)
  1712. await db_session.commit()
  1713. await db_session.refresh(threemf)
  1714. call_count = {"n": 0}
  1715. def handler(request: httpx.Request) -> httpx.Response:
  1716. if not _is_slice_post(request):
  1717. return httpx.Response(404)
  1718. call_count["n"] += 1
  1719. return httpx.Response(
  1720. status_code=500,
  1721. json={
  1722. "message": (
  1723. "Slicing failed with error from slicer: Some objects are "
  1724. "located over the boundary of the heated bed."
  1725. ),
  1726. "details": "Slicer process failed (exit code 204)",
  1727. },
  1728. )
  1729. _install_mock_sidecar(handler)
  1730. response = await async_client.post(
  1731. f"/api/v1/library/files/{threemf.id}/slice",
  1732. json={
  1733. "printer_preset_id": slice_test_setup["printer_id"],
  1734. "process_preset_id": slice_test_setup["process_id"],
  1735. "filament_preset_id": slice_test_setup["filament_id"],
  1736. },
  1737. )
  1738. assert response.status_code == 202
  1739. final = await _wait_for_job(async_client, response.json()["job_id"])
  1740. assert final["status"] == "failed", final
  1741. assert final["error_status"] == 400
  1742. assert "boundary of the heated bed" in (final["error_detail"] or "")
  1743. # The slicer rejection must NOT trigger the embedded-settings retry.
  1744. assert call_count["n"] == 1
  1745. # ---------------------------------------------------------------------------
  1746. # Nozzle-class re-slice guard — single-nozzle <-> dual-nozzle (H2D) is blocked
  1747. # ---------------------------------------------------------------------------
  1748. from fastapi import HTTPException # noqa: E402
  1749. from backend.app.api.routes.library import ( # noqa: E402
  1750. _canonical_printer_model,
  1751. guard_nozzle_class_reslice,
  1752. )
  1753. class TestCanonicalPrinterModel:
  1754. """_canonical_printer_model strips the '# ' clone prefix and the
  1755. ' 0.4 nozzle' variant suffix so preset names resolve to a model code."""
  1756. def test_strips_nozzle_suffix(self):
  1757. assert _canonical_printer_model("Bambu Lab H2D 0.4 nozzle") == "H2D"
  1758. def test_strips_clone_prefix_and_suffix(self):
  1759. assert _canonical_printer_model("# Bambu Lab X1 Carbon 0.4 nozzle") == "X1C"
  1760. def test_bare_model_and_empty(self):
  1761. assert _canonical_printer_model("Bambu Lab H2D") == "H2D"
  1762. assert _canonical_printer_model(None) is None
  1763. assert _canonical_printer_model("") is None
  1764. class TestNozzleClassGuard:
  1765. """guard_nozzle_class_reslice is now a no-op (#1493). Cross-class re-slicing
  1766. is handled by the two-pass conversion in _run_slicer_with_fallback — so the
  1767. guard never blocks. The function is kept (and these tests with it) so
  1768. external forks / pinned versions that call it still link, and so a future
  1769. regression that re-introduces a raise inside the helper gets caught here."""
  1770. @staticmethod
  1771. def _request() -> object:
  1772. return type("_Req", (), {})()
  1773. @pytest.mark.asyncio
  1774. async def test_single_to_dual_is_allowed(self, monkeypatch):
  1775. """Cross-class re-slice: handled by the two-pass converter, so the
  1776. guard does NOT raise."""
  1777. import backend.app.api.routes.library as lib
  1778. async def _target(_db, _user, _request):
  1779. return "H2D"
  1780. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1781. # No raise — the converter handles this case now.
  1782. await guard_nozzle_class_reslice(None, None, self._request(), "X1C")
  1783. @pytest.mark.asyncio
  1784. async def test_dual_to_single_is_allowed(self, monkeypatch):
  1785. import backend.app.api.routes.library as lib
  1786. async def _target(_db, _user, _request):
  1787. return "X1C"
  1788. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1789. await guard_nozzle_class_reslice(None, None, self._request(), "H2D")
  1790. @pytest.mark.asyncio
  1791. async def test_same_nozzle_class_is_allowed(self, monkeypatch):
  1792. import backend.app.api.routes.library as lib
  1793. async def _target(_db, _user, _request):
  1794. return "P1S"
  1795. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1796. await guard_nozzle_class_reslice(None, None, self._request(), "X1C")
  1797. @pytest.mark.asyncio
  1798. async def test_no_source_model_is_a_noop(self, monkeypatch):
  1799. import backend.app.api.routes.library as lib
  1800. async def _target(_db, _user, _request):
  1801. return "H2D"
  1802. monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
  1803. await guard_nozzle_class_reslice(None, None, self._request(), None)
  1804. @pytest.mark.asyncio
  1805. async def test_null_request_is_a_noop(self):
  1806. await guard_nozzle_class_reslice(None, None, None, "X1C")
  1807. @pytest.mark.asyncio
  1808. @pytest.mark.integration
  1809. async def test_archive_reslice_x1c_to_h2d_preset_path_is_not_400(
  1810. self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
  1811. ):
  1812. """End to end: the preset-driven archive re-slice from X1C to H2D no
  1813. longer gets a synchronous 400 from the guard. It may still fail
  1814. downstream (no sidecar in test env), but it must not be rejected by
  1815. the nozzle-class guard's old "isn't supported yet" message."""
  1816. tmp_path = slice_test_setup["tmp_path"]
  1817. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  1818. src_dir = tmp_path / "archives" / "src"
  1819. src_dir.mkdir(parents=True, exist_ok=True)
  1820. src_3mf = src_dir / "cube.3mf"
  1821. src_3mf.write_bytes(_make_3mf_with_settings())
  1822. printer = await printer_factory()
  1823. source = await archive_factory(
  1824. printer.id,
  1825. filename="cube.3mf",
  1826. file_path=str(src_3mf.relative_to(tmp_path)),
  1827. sliced_for_model="X1C",
  1828. with_run=False,
  1829. )
  1830. h2d = LocalPreset(
  1831. name="# Bambu Lab H2D 0.4 nozzle",
  1832. preset_type="printer",
  1833. source="orcaslicer",
  1834. setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
  1835. )
  1836. db_session.add(h2d)
  1837. await db_session.commit()
  1838. await db_session.refresh(h2d)
  1839. resp = await async_client.post(
  1840. f"/api/v1/archives/{source.id}/slice",
  1841. json={
  1842. "printer_preset": {"source": "local", "id": str(h2d.id)},
  1843. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  1844. "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
  1845. },
  1846. )
  1847. if resp.status_code == 400:
  1848. detail = resp.json().get("detail", "")
  1849. assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
  1850. class TestUnusedSlotSubstitutionOnSinglePlateSource:
  1851. """#2711: a single-plate 3MF must still get its unused slots substituted.
  1852. The SliceModal omits ``plate`` entirely for single-plate and STL sources —
  1853. it skips the plate picker, so ``selectedPlate`` stays null and the field
  1854. never reaches the body. The schema documents an absent plate as "plate 1",
  1855. but the substitution used to read it as "unknown plate" and skip, so every
  1856. single-plate project reached the CLI with the dropdown values of slots the
  1857. plate never paints with.
  1858. In the reported case that was a MakerWorld project declaring four filaments
  1859. while plate 1 paints with one, the other three carrying presets baked into
  1860. the source for a different printer. The CLI rejected the whole slice with
  1861. "filament preset ... (slot 1) is not compatible with printer ...", and the
  1862. modal disables unused rows so there was no way to correct it by hand.
  1863. """
  1864. @staticmethod
  1865. def _single_plate_using_only_slot_3() -> bytes:
  1866. """One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
  1867. declared by the project but unused. Mirrors the reported file."""
  1868. buf = io.BytesIO()
  1869. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1870. zf.writestr("3D/3dmodel.model", "<model/>")
  1871. zf.writestr(
  1872. "Metadata/project_settings.config",
  1873. json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
  1874. )
  1875. zf.writestr(
  1876. "Metadata/model_settings.config",
  1877. "<?xml version='1.0'?>\n<config>"
  1878. '<object id="1"><metadata key="extruder" value="3"/></object>'
  1879. '<plate><metadata key="plater_id" value="1"/>'
  1880. '<model_instance><metadata key="object_id" value="1"/>'
  1881. '<metadata key="instance_id" value="0"/></model_instance>'
  1882. "</plate></config>",
  1883. )
  1884. return buf.getvalue()
  1885. @staticmethod
  1886. def _filament_names_sent(body: bytes) -> list[str]:
  1887. """Pull the ``name`` of each ``filamentProfile`` part, in slot order.
  1888. ``slice_model`` sends one repeated ``filamentProfile`` part per slot as
  1889. ``filament_N.json``; the parts stay in submission order, so a plain
  1890. scan preserves the slot mapping.
  1891. """
  1892. names: list[str] = []
  1893. marker = b'name="filamentProfile"; filename="filament_'
  1894. pos = body.find(marker)
  1895. while pos != -1:
  1896. start = body.find(b"{", pos)
  1897. end = body.find(b"\r\n", start)
  1898. names.append(json.loads(body[start:end].decode("utf-8"))["name"])
  1899. pos = body.find(marker, end)
  1900. return names
  1901. @pytest.mark.asyncio
  1902. @pytest.mark.integration
  1903. async def test_unused_slots_are_substituted_when_the_body_omits_plate(
  1904. self, async_client: AsyncClient, db_session, slice_test_setup
  1905. ):
  1906. tmp_path = slice_test_setup["tmp_path"]
  1907. src = tmp_path / "library" / "files" / "train.3mf"
  1908. src.write_bytes(self._single_plate_using_only_slot_3())
  1909. threemf = LibraryFile(
  1910. filename="train.3mf",
  1911. file_path=str(src.relative_to(tmp_path)),
  1912. file_type="3mf",
  1913. file_size=src.stat().st_size,
  1914. )
  1915. db_session.add(threemf)
  1916. # Four distinguishable filament presets, one per project slot. Only
  1917. # slot 3's is compatible with the target in the reported scenario.
  1918. slots = []
  1919. for i in range(1, 5):
  1920. p = LocalPreset(
  1921. name=f"slot{i}",
  1922. preset_type="filament",
  1923. source="orcaslicer",
  1924. setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
  1925. )
  1926. db_session.add(p)
  1927. slots.append(p)
  1928. await db_session.commit()
  1929. await db_session.refresh(threemf)
  1930. for p in slots:
  1931. await db_session.refresh(p)
  1932. captured: list[list[str]] = []
  1933. def handler(request: httpx.Request) -> httpx.Response:
  1934. if not _is_slice_post(request):
  1935. return httpx.Response(404)
  1936. captured.append(self._filament_names_sent(request.content))
  1937. return httpx.Response(
  1938. status_code=200,
  1939. content=_make_3mf_with_settings(),
  1940. headers={
  1941. "x-print-time-seconds": "100",
  1942. "x-filament-used-g": "1.0",
  1943. "x-filament-used-mm": "100",
  1944. },
  1945. )
  1946. _install_mock_sidecar(handler)
  1947. response = await async_client.post(
  1948. f"/api/v1/library/files/{threemf.id}/slice",
  1949. json={
  1950. "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
  1951. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  1952. "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
  1953. # No "plate" — exactly what the modal sends for a single-plate
  1954. # source. This is the whole point of the test.
  1955. },
  1956. )
  1957. assert response.status_code == 202, response.text
  1958. final = await _wait_for_job(async_client, response.json()["job_id"])
  1959. assert final["status"] == "completed", final
  1960. assert captured, "sidecar was never called"
  1961. # Every slot carries slot 3's profile: the array length stays intact
  1962. # (the source's per-slot references depend on it) while nothing the
  1963. # plate doesn't print with can fail the CLI's validators.
  1964. assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
  1965. @pytest.mark.asyncio
  1966. @pytest.mark.integration
  1967. async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
  1968. """``plate=0`` is the all-plates sentinel, so nothing is unused.
  1969. It reaches the same call site, and plate ids are 1-indexed — the
  1970. geometry lookup for plate 0 matches nothing. Without an explicit
  1971. exclusion the project's support-filament slot would be the only
  1972. member of the used set and would be copied over every colour.
  1973. """
  1974. tmp_path = slice_test_setup["tmp_path"]
  1975. buf = io.BytesIO()
  1976. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1977. zf.writestr("3D/3dmodel.model", "<model/>")
  1978. zf.writestr(
  1979. "Metadata/project_settings.config",
  1980. json.dumps(
  1981. {
  1982. "enable_support": "1",
  1983. "support_filament": "4",
  1984. "support_interface_filament": "4",
  1985. "filament_type": ["PLA", "PLA", "PLA", "PVA"],
  1986. }
  1987. ),
  1988. )
  1989. zf.writestr(
  1990. "Metadata/model_settings.config",
  1991. "<?xml version='1.0'?>\n<config>"
  1992. '<object id="1"><metadata key="extruder" value="1"/></object>'
  1993. '<object id="2"><metadata key="extruder" value="2"/></object>'
  1994. '<plate><metadata key="plater_id" value="1"/>'
  1995. '<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
  1996. '<plate><metadata key="plater_id" value="2"/>'
  1997. '<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
  1998. "</config>",
  1999. )
  2000. src = tmp_path / "library" / "files" / "multi.3mf"
  2001. src.write_bytes(buf.getvalue())
  2002. threemf = LibraryFile(
  2003. filename="multi.3mf",
  2004. file_path=str(src.relative_to(tmp_path)),
  2005. file_type="3mf",
  2006. file_size=src.stat().st_size,
  2007. )
  2008. db_session.add(threemf)
  2009. slots = []
  2010. for i in range(1, 5):
  2011. p = LocalPreset(
  2012. name=f"slot{i}",
  2013. preset_type="filament",
  2014. source="orcaslicer",
  2015. setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
  2016. )
  2017. db_session.add(p)
  2018. slots.append(p)
  2019. await db_session.commit()
  2020. await db_session.refresh(threemf)
  2021. for p in slots:
  2022. await db_session.refresh(p)
  2023. captured: list[list[str]] = []
  2024. def handler(request: httpx.Request) -> httpx.Response:
  2025. if not _is_slice_post(request):
  2026. return httpx.Response(404)
  2027. captured.append(self._filament_names_sent(request.content))
  2028. return httpx.Response(
  2029. status_code=200,
  2030. content=_make_3mf_with_settings(),
  2031. headers={
  2032. "x-print-time-seconds": "100",
  2033. "x-filament-used-g": "1.0",
  2034. "x-filament-used-mm": "100",
  2035. },
  2036. )
  2037. _install_mock_sidecar(handler)
  2038. response = await async_client.post(
  2039. f"/api/v1/library/files/{threemf.id}/slice",
  2040. json={
  2041. "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
  2042. "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
  2043. "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
  2044. "plate": 0,
  2045. },
  2046. )
  2047. assert response.status_code == 202, response.text
  2048. final = await _wait_for_job(async_client, response.json()["job_id"])
  2049. assert final["status"] == "completed", final
  2050. assert captured, "sidecar was never called"
  2051. assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]
  2052. class TestFilamentRequirementsFullSlots:
  2053. """#2712: what the slice modal is handed must be positional.
  2054. The modal's filament list maps index 0 to slot 1, and the backend forwards
  2055. it in that order as ``filament_1.json``..``filament_N.json``. A MakerWorld
  2056. source that ships slice_info and paints with slot 4 alone therefore has to
  2057. present four rows — a one-row list binds the user's pick to slot 1, and
  2058. slot 4 slices with whatever the source had baked in. Picking PETG produced
  2059. a PLA print, and the print dialog then correctly refused to match PETG.
  2060. Print-time AMS matching shares this endpoint and needs the opposite: only
  2061. the slots the plate consumes, so it doesn't demand spools for slots the
  2062. G-code never touches. Hence the opt-in flag rather than a shape change.
  2063. """
  2064. @staticmethod
  2065. def _sliced_source_using_only_slot_4() -> bytes:
  2066. buf = io.BytesIO()
  2067. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  2068. zf.writestr("3D/3dmodel.model", "<model/>")
  2069. zf.writestr(
  2070. "Metadata/project_settings.config",
  2071. json.dumps(
  2072. {
  2073. "filament_type": ["PLA", "PLA", "PLA", "PLA"],
  2074. "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
  2075. }
  2076. ),
  2077. )
  2078. # MakerWorld ships slice_info without plate G-code, which is what
  2079. # sends this file down the "already sliced" branch.
  2080. zf.writestr(
  2081. "Metadata/slice_info.config",
  2082. "<?xml version='1.0'?>\n<config><plate>"
  2083. "<metadata key='index' value='1'/>"
  2084. "<filament id='4' tray_info_idx='GFL99' type='PLA' color='#898989'"
  2085. " used_m='35.51' used_g='105.92'/>"
  2086. "</plate></config>",
  2087. )
  2088. return buf.getvalue()
  2089. async def _make_file(self, db_session, tmp_path) -> int:
  2090. src = tmp_path / "library" / "files" / "tunnel.3mf"
  2091. src.write_bytes(self._sliced_source_using_only_slot_4())
  2092. lib = LibraryFile(
  2093. filename="tunnel.3mf",
  2094. file_path=str(src.relative_to(tmp_path)),
  2095. file_type="3mf",
  2096. file_size=src.stat().st_size,
  2097. )
  2098. db_session.add(lib)
  2099. await db_session.commit()
  2100. await db_session.refresh(lib)
  2101. return lib.id
  2102. @pytest.mark.asyncio
  2103. @pytest.mark.integration
  2104. async def test_full_slots_returns_one_row_per_project_slot(
  2105. self, async_client: AsyncClient, db_session, slice_test_setup
  2106. ):
  2107. file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
  2108. r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1&full_slots=true")
  2109. assert r.status_code == 200, r.text
  2110. filaments = r.json()["filaments"]
  2111. assert [f["slot_id"] for f in filaments] == [1, 2, 3, 4]
  2112. # Only slot 4 prints, so only its row is selectable in the modal.
  2113. assert [f["used_in_plate"] for f in filaments] == [False, False, False, True]
  2114. # The used row keeps what the slice actually reported.
  2115. assert filaments[3]["used_grams"] == 105.9
  2116. assert filaments[3]["tray_info_idx"] == "GFL99"
  2117. @pytest.mark.asyncio
  2118. @pytest.mark.integration
  2119. async def test_the_print_path_still_gets_only_the_used_slot(
  2120. self, async_client: AsyncClient, db_session, slice_test_setup
  2121. ):
  2122. """Without the flag the response must be byte-for-byte what it was.
  2123. PrintModal drives AMS matching off this; widening it would ask the
  2124. user to load three spools the print never touches.
  2125. """
  2126. file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
  2127. r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1")
  2128. assert r.status_code == 200, r.text
  2129. filaments = r.json()["filaments"]
  2130. assert [f["slot_id"] for f in filaments] == [4]
  2131. assert filaments[0]["used_in_plate"] is True
  2132. @pytest.mark.asyncio
  2133. @pytest.mark.integration
  2134. async def test_unsliced_sources_are_unaffected_by_the_flag(
  2135. self, async_client: AsyncClient, db_session, slice_test_setup
  2136. ):
  2137. """Those already returned the full project list; the flag must not
  2138. double-handle them or change what the modal has been getting."""
  2139. tmp_path = slice_test_setup["tmp_path"]
  2140. buf = io.BytesIO()
  2141. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  2142. zf.writestr("3D/3dmodel.model", "<model/>")
  2143. zf.writestr(
  2144. "Metadata/project_settings.config",
  2145. json.dumps({"filament_type": ["PLA", "PETG"], "filament_colour": ["#000000", "#FFFFFF"]}),
  2146. )
  2147. src = tmp_path / "library" / "files" / "raw.3mf"
  2148. src.write_bytes(buf.getvalue())
  2149. lib = LibraryFile(
  2150. filename="raw.3mf",
  2151. file_path=str(src.relative_to(tmp_path)),
  2152. file_type="3mf",
  2153. file_size=src.stat().st_size,
  2154. )
  2155. db_session.add(lib)
  2156. await db_session.commit()
  2157. await db_session.refresh(lib)
  2158. with_flag = await async_client.get(
  2159. f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1&full_slots=true"
  2160. )
  2161. without = await async_client.get(f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1")
  2162. assert with_flag.status_code == 200 and without.status_code == 200
  2163. assert with_flag.json()["filaments"] == without.json()["filaments"]
  2164. assert [f["slot_id"] for f in with_flag.json()["filaments"]] == [1, 2]