test_gcode_injection.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. """Unit tests for G-code injection into 3MF files (#422)."""
  2. import hashlib
  3. import tempfile
  4. import zipfile
  5. from pathlib import Path
  6. from backend.app.utils.threemf_tools import (
  7. _inject_start_at_marker,
  8. _parse_3mf_gcode_header,
  9. _substitute_placeholders,
  10. inject_gcode_into_3mf,
  11. )
  12. def _make_temp_path(suffix=".3mf") -> Path:
  13. """Create a temp file path without leaving it open (avoids SIM115)."""
  14. fd, name = tempfile.mkstemp(suffix=suffix)
  15. import os
  16. os.close(fd)
  17. return Path(name)
  18. def _make_test_3mf(gcode_content: str = "G28\nG1 X0 Y0\nM400\n", plate_id: int = 1) -> Path:
  19. """Create a minimal 3MF file with embedded G-code for testing."""
  20. tmp_path = _make_temp_path()
  21. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
  22. zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode_content)
  23. zf.writestr("Metadata/slice_info.config", "<config></config>")
  24. zf.writestr("3D/3dmodel.model", "<model></model>")
  25. return tmp_path
  26. class TestInjectGcodeInto3mf:
  27. """Tests for inject_gcode_into_3mf()."""
  28. def test_inject_start_gcode(self):
  29. """Start G-code is prepended before the original content."""
  30. source = _make_test_3mf("G28\nM400\n")
  31. try:
  32. result = inject_gcode_into_3mf(source, 1, "M117 Start\nG92 E0", None)
  33. assert result is not None
  34. with zipfile.ZipFile(result, "r") as zf:
  35. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  36. assert gcode.startswith("M117 Start\nG92 E0\n")
  37. assert "G28\nM400\n" in gcode
  38. finally:
  39. source.unlink(missing_ok=True)
  40. if result:
  41. result.unlink(missing_ok=True)
  42. def test_inject_end_gcode(self):
  43. """End G-code is appended after the original content."""
  44. source = _make_test_3mf("G28\nM400")
  45. try:
  46. result = inject_gcode_into_3mf(source, 1, None, "M104 S0\nG28 X")
  47. assert result is not None
  48. with zipfile.ZipFile(result, "r") as zf:
  49. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  50. assert gcode.endswith("M104 S0\nG28 X\n")
  51. assert gcode.startswith("G28\nM400")
  52. finally:
  53. source.unlink(missing_ok=True)
  54. if result:
  55. result.unlink(missing_ok=True)
  56. def test_inject_both_start_and_end(self):
  57. """Both start and end G-code are injected."""
  58. source = _make_test_3mf("G28\n")
  59. try:
  60. result = inject_gcode_into_3mf(source, 1, "; START", "; END")
  61. assert result is not None
  62. with zipfile.ZipFile(result, "r") as zf:
  63. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  64. assert gcode.startswith("; START\n")
  65. assert gcode.endswith("; END\n")
  66. assert "G28" in gcode
  67. finally:
  68. source.unlink(missing_ok=True)
  69. if result:
  70. result.unlink(missing_ok=True)
  71. def test_no_injection_returns_none(self):
  72. """Returns None when both start and end are None."""
  73. source = _make_test_3mf()
  74. try:
  75. result = inject_gcode_into_3mf(source, 1, None, None)
  76. assert result is None
  77. finally:
  78. source.unlink(missing_ok=True)
  79. def test_empty_strings_returns_none(self):
  80. """Returns None when both start and end are empty strings."""
  81. source = _make_test_3mf()
  82. try:
  83. result = inject_gcode_into_3mf(source, 1, "", "")
  84. assert result is None
  85. finally:
  86. source.unlink(missing_ok=True)
  87. def test_plate_id_selection(self):
  88. """Injects into the correct plate's G-code file."""
  89. source = _make_temp_path()
  90. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  91. zf.writestr("Metadata/plate_1.gcode", "PLATE1\n")
  92. zf.writestr("Metadata/plate_2.gcode", "PLATE2\n")
  93. try:
  94. result = inject_gcode_into_3mf(source, 2, "; INJECTED", None)
  95. assert result is not None
  96. with zipfile.ZipFile(result, "r") as zf:
  97. plate1 = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  98. plate2 = zf.read("Metadata/plate_2.gcode").decode("utf-8")
  99. # Only plate 2 should be modified
  100. assert plate1 == "PLATE1\n"
  101. assert plate2.startswith("; INJECTED\n")
  102. finally:
  103. source.unlink(missing_ok=True)
  104. if result:
  105. result.unlink(missing_ok=True)
  106. def test_preserves_other_files(self):
  107. """Non-gcode files in the 3MF are preserved unchanged."""
  108. source = _make_test_3mf()
  109. try:
  110. result = inject_gcode_into_3mf(source, 1, "; START", None)
  111. assert result is not None
  112. with zipfile.ZipFile(result, "r") as zf:
  113. names = zf.namelist()
  114. assert "Metadata/slice_info.config" in names
  115. assert "3D/3dmodel.model" in names
  116. config = zf.read("Metadata/slice_info.config").decode("utf-8")
  117. assert config == "<config></config>"
  118. finally:
  119. source.unlink(missing_ok=True)
  120. if result:
  121. result.unlink(missing_ok=True)
  122. def test_no_gcode_file_returns_none(self):
  123. """Returns None when the 3MF has no gcode files."""
  124. source = _make_temp_path()
  125. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  126. zf.writestr("3D/3dmodel.model", "<model></model>")
  127. try:
  128. result = inject_gcode_into_3mf(source, 1, "; START", None)
  129. assert result is None
  130. finally:
  131. source.unlink(missing_ok=True)
  132. def test_invalid_file_returns_none(self):
  133. """Returns None for a non-ZIP file."""
  134. source = _make_temp_path()
  135. source.write_bytes(b"not a zip file")
  136. try:
  137. result = inject_gcode_into_3mf(source, 1, "; START", None)
  138. assert result is None
  139. finally:
  140. source.unlink(missing_ok=True)
  141. def test_fallback_to_first_gcode(self):
  142. """Falls back to first gcode file when plate-specific not found."""
  143. source = _make_temp_path()
  144. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  145. zf.writestr("Metadata/plate_1.gcode", "ORIGINAL\n")
  146. try:
  147. # Request plate 5 which doesn't exist — should fall back to plate_1
  148. result = inject_gcode_into_3mf(source, 5, "; INJECTED", None)
  149. assert result is not None
  150. with zipfile.ZipFile(result, "r") as zf:
  151. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  152. assert gcode.startswith("; INJECTED\n")
  153. finally:
  154. source.unlink(missing_ok=True)
  155. if result:
  156. result.unlink(missing_ok=True)
  157. def test_original_file_unchanged(self):
  158. """The source 3MF is never modified."""
  159. source = _make_test_3mf("ORIGINAL\n")
  160. try:
  161. result = inject_gcode_into_3mf(source, 1, "; START", "; END")
  162. assert result is not None
  163. # Verify original is untouched
  164. with zipfile.ZipFile(source, "r") as zf:
  165. original = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  166. assert original == "ORIGINAL\n"
  167. finally:
  168. source.unlink(missing_ok=True)
  169. if result:
  170. result.unlink(missing_ok=True)
  171. # Realistic Bambu / Orca header + startup block — the start-gcode marker is the
  172. # anchor point #422 reviewers (DevScarabyte, pleite) reported as the correct
  173. # injection point. Snippets injected before this should land *after* the bed
  174. # heat / homing / nozzle prime sequence, not before it.
  175. _BAMBU_GCODE_TEMPLATE = """\
  176. ; HEADER_BLOCK_START
  177. ; BambuStudio 02.06.00.51
  178. ; total layer number: 80
  179. ; total filament length [mm] : 12155.34
  180. ; total filament weight [g] : 36.55
  181. ; max_z_height: 16.00
  182. ; HEADER_BLOCK_END
  183. ; MACHINE_START_GCODE_BEGIN
  184. M104 S220 ; preheat
  185. G28 ; home
  186. M109 S220 ; wait for nozzle
  187. G92 E0 ; reset extruder
  188. ; MACHINE_START_GCODE_END
  189. G1 X10 Y10 Z0.2
  190. G1 X100 Y100 E5
  191. M104 S0
  192. """
  193. class TestMd5SidecarRecompute:
  194. """The plate `.gcode.md5` sidecar must match the injected gcode (P1S rejects
  195. a stale hash with HMS 0500-4003)."""
  196. def _make_3mf_with_md5(self, gcode: str, plate_id: int = 1) -> Path:
  197. """A 3MF that carries a (deliberately wrong) md5 sidecar, like a real
  198. sliced .gcode.3mf does."""
  199. tmp_path = _make_temp_path()
  200. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
  201. zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode)
  202. zf.writestr(f"Metadata/plate_{plate_id}.gcode.md5", "STALEHASHVALUE")
  203. zf.writestr("Metadata/slice_info.config", "<config></config>")
  204. return tmp_path
  205. def test_md5_recomputed_to_match_injected_gcode(self):
  206. source = self._make_3mf_with_md5("G28\nM400\n")
  207. result = None
  208. try:
  209. result = inject_gcode_into_3mf(source, 1, None, "M104 S0")
  210. assert result is not None
  211. with zipfile.ZipFile(result, "r") as zf:
  212. gcode = zf.read("Metadata/plate_1.gcode")
  213. sidecar = zf.read("Metadata/plate_1.gcode.md5")
  214. expected = hashlib.md5(gcode, usedforsecurity=False).hexdigest().upper().encode("ascii")
  215. assert sidecar == expected
  216. assert sidecar != b"STALEHASHVALUE"
  217. finally:
  218. source.unlink(missing_ok=True)
  219. if result:
  220. result.unlink(missing_ok=True)
  221. def test_sidecar_is_uppercase_hex_no_newline(self):
  222. """Match Bambu's on-disk format exactly: uppercase, no trailing newline."""
  223. source = self._make_3mf_with_md5("G28\n")
  224. result = None
  225. try:
  226. result = inject_gcode_into_3mf(source, 1, "; START", None)
  227. assert result is not None
  228. with zipfile.ZipFile(result, "r") as zf:
  229. sidecar = zf.read("Metadata/plate_1.gcode.md5")
  230. assert sidecar == sidecar.upper()
  231. assert not sidecar.endswith(b"\n")
  232. assert len(sidecar) == 32
  233. finally:
  234. source.unlink(missing_ok=True)
  235. if result:
  236. result.unlink(missing_ok=True)
  237. def test_no_md5_member_is_not_created(self):
  238. """A 3MF without an md5 sidecar shouldn't gain one (firmware isn't
  239. validating it, and inventing a member could surprise older files)."""
  240. source = _make_test_3mf("G28\n") # no .md5 member
  241. result = None
  242. try:
  243. result = inject_gcode_into_3mf(source, 1, "; START", None)
  244. assert result is not None
  245. with zipfile.ZipFile(result, "r") as zf:
  246. names = zf.namelist()
  247. assert "Metadata/plate_1.gcode.md5" not in names
  248. finally:
  249. source.unlink(missing_ok=True)
  250. if result:
  251. result.unlink(missing_ok=True)
  252. def test_other_member_compression_preserved(self):
  253. """Non-target members keep their original compression (P1S preview
  254. parser chokes on re-DEFLATEd STORE'd PNGs)."""
  255. tmp_path = _make_temp_path()
  256. with zipfile.ZipFile(tmp_path, "w") as zf:
  257. zf.writestr(zipfile.ZipInfo("Metadata/plate_1.gcode"), "G28\n")
  258. # A STORE'd member (compress_type=0), like an embedded preview PNG.
  259. stored = zipfile.ZipInfo("Metadata/plate_1.png")
  260. stored.compress_type = zipfile.ZIP_STORED
  261. zf.writestr(stored, b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
  262. result = None
  263. try:
  264. result = inject_gcode_into_3mf(tmp_path, 1, None, "; END")
  265. assert result is not None
  266. with zipfile.ZipFile(result, "r") as zf:
  267. assert zf.getinfo("Metadata/plate_1.png").compress_type == zipfile.ZIP_STORED
  268. finally:
  269. tmp_path.unlink(missing_ok=True)
  270. if result:
  271. result.unlink(missing_ok=True)
  272. class TestStartAnchoredInjection:
  273. """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
  274. def test_start_lands_after_printer_startup(self):
  275. """Start snippet sits immediately before MACHINE_START_GCODE_END, not at file head."""
  276. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  277. try:
  278. result = inject_gcode_into_3mf(source, 1, "; SWAPMOD-START", None)
  279. assert result is not None
  280. with zipfile.ZipFile(result, "r") as zf:
  281. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  282. # Original file head is preserved — snippet does NOT prepend.
  283. assert gcode.startswith("; HEADER_BLOCK_START\n")
  284. # Snippet sits right above the marker.
  285. marker_idx = gcode.index("; MACHINE_START_GCODE_END")
  286. snippet_idx = gcode.index("; SWAPMOD-START")
  287. assert snippet_idx < marker_idx
  288. # Nothing else between snippet and marker except the trailing newline.
  289. between = gcode[snippet_idx:marker_idx]
  290. assert between == "; SWAPMOD-START\n"
  291. # Printer's own startup commands still come BEFORE the snippet.
  292. startup_idx = gcode.index("M109 S220")
  293. assert startup_idx < snippet_idx
  294. finally:
  295. source.unlink(missing_ok=True)
  296. if result:
  297. result.unlink(missing_ok=True)
  298. def test_no_marker_falls_back_to_prepend(self):
  299. """Files without MACHINE_START_GCODE_END (older slicers) keep prepend behaviour."""
  300. source = _make_test_3mf("G28\nM400\n")
  301. try:
  302. result = inject_gcode_into_3mf(source, 1, "; LEGACY-START", None)
  303. assert result is not None
  304. with zipfile.ZipFile(result, "r") as zf:
  305. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  306. assert gcode.startswith("; LEGACY-START\n")
  307. assert "G28" in gcode
  308. finally:
  309. source.unlink(missing_ok=True)
  310. if result:
  311. result.unlink(missing_ok=True)
  312. def test_end_falls_back_to_eof_without_block_marker(self):
  313. """Files without ; EXECUTABLE_BLOCK_END (older / non-Bambu slicers) keep the
  314. append-to-EOF fallback for end snippets."""
  315. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE) # template has no EXECUTABLE_BLOCK_END
  316. try:
  317. result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
  318. assert result is not None
  319. with zipfile.ZipFile(result, "r") as zf:
  320. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  321. assert gcode.endswith("; SWAPMOD-END\n")
  322. finally:
  323. source.unlink(missing_ok=True)
  324. if result:
  325. result.unlink(missing_ok=True)
  326. def test_end_lands_before_executable_block_end(self):
  327. """With ; EXECUTABLE_BLOCK_END present, the end snippet sits INSIDE the
  328. executable block (just before the marker). Bambu firmware (P1S) does not
  329. run g-code placed after that marker, so appending to EOF would silently
  330. drop auto-eject / plate-clear moves."""
  331. gcode_src = (
  332. "; HEADER_BLOCK_START\n; max_z_height: 16.00\n; HEADER_BLOCK_END\n"
  333. "; MACHINE_START_GCODE_END\n"
  334. "G1 X10 Y10 Z0.2\n"
  335. "M104 S0 ; printer machine-end\n"
  336. "; EXECUTABLE_BLOCK_END\n"
  337. )
  338. source = _make_test_3mf(gcode_src)
  339. try:
  340. result = inject_gcode_into_3mf(source, 1, None, "; EJECT-SWEEP")
  341. assert result is not None
  342. with zipfile.ZipFile(result, "r") as zf:
  343. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  344. snippet_idx = gcode.index("; EJECT-SWEEP")
  345. marker_idx = gcode.index("; EXECUTABLE_BLOCK_END")
  346. # Snippet is inside the block, before the end marker.
  347. assert snippet_idx < marker_idx
  348. # The printer's own machine-end still precedes our snippet.
  349. assert gcode.index("M104 S0 ; printer machine-end") < snippet_idx
  350. # Nothing executable remains after the marker.
  351. assert gcode[marker_idx:].strip() == "; EXECUTABLE_BLOCK_END"
  352. finally:
  353. source.unlink(missing_ok=True)
  354. if result:
  355. result.unlink(missing_ok=True)
  356. class TestPlaceholderSubstitution:
  357. """Tests for #422 follow-up: {placeholder} substitution from 3MF header values."""
  358. def test_max_z_height_substituted_in_end_snippet(self):
  359. """`G1 Z{max_layer_z}` resolves to the model's actual top-layer Z (DevScarabyte safety bug)."""
  360. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  361. try:
  362. # Prusa-style alias: max_layer_z → max_z_height in the Bambu header
  363. result = inject_gcode_into_3mf(source, 1, None, "G1 Z{max_layer_z} F600")
  364. assert result is not None
  365. with zipfile.ZipFile(result, "r") as zf:
  366. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  367. # max_z_height in the template is 16.00 — the dangerous Z1 fallback is gone.
  368. assert "G1 Z16.00 F600" in gcode
  369. assert "{max_layer_z}" not in gcode
  370. finally:
  371. source.unlink(missing_ok=True)
  372. if result:
  373. result.unlink(missing_ok=True)
  374. def test_direct_header_key_lookup(self):
  375. """Snippets can reference normalised header keys directly without going through aliases."""
  376. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  377. try:
  378. result = inject_gcode_into_3mf(
  379. source, 1, None, "; layers={total_layer_number} weight={total_filament_weight}"
  380. )
  381. assert result is not None
  382. with zipfile.ZipFile(result, "r") as zf:
  383. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  384. assert "; layers=80 weight=36.55" in gcode
  385. finally:
  386. source.unlink(missing_ok=True)
  387. if result:
  388. result.unlink(missing_ok=True)
  389. def test_unknown_placeholder_left_intact(self):
  390. """A typo or unsupported placeholder is preserved verbatim instead of becoming empty."""
  391. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  392. try:
  393. result = inject_gcode_into_3mf(source, 1, None, "; nope={does_not_exist}")
  394. assert result is not None
  395. with zipfile.ZipFile(result, "r") as zf:
  396. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  397. assert "; nope={does_not_exist}" in gcode
  398. finally:
  399. source.unlink(missing_ok=True)
  400. if result:
  401. result.unlink(missing_ok=True)
  402. def test_no_placeholders_no_header_required(self):
  403. """Snippets without placeholders inject correctly even when the header is absent."""
  404. source = _make_test_3mf("G28\nM400\n")
  405. try:
  406. result = inject_gcode_into_3mf(source, 1, "; PLAIN", None)
  407. assert result is not None
  408. with zipfile.ZipFile(result, "r") as zf:
  409. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  410. assert gcode.startswith("; PLAIN\n")
  411. finally:
  412. source.unlink(missing_ok=True)
  413. if result:
  414. result.unlink(missing_ok=True)
  415. class TestHeaderParser:
  416. """Direct tests for `_parse_3mf_gcode_header`."""
  417. def test_parses_bambu_header_block(self):
  418. header = _parse_3mf_gcode_header(_BAMBU_GCODE_TEMPLATE)
  419. assert header["max_z_height"] == "16.00"
  420. assert header["total_layer_number"] == "80"
  421. # Units suffix is stripped from the key.
  422. assert header["total_filament_length"] == "12155.34"
  423. assert header["total_filament_weight"] == "36.55"
  424. def test_ignores_lines_outside_header_block(self):
  425. content = "; HEADER_BLOCK_START\n; key: in\n; HEADER_BLOCK_END\n; key: out\n"
  426. header = _parse_3mf_gcode_header(content)
  427. assert header == {"key": "in"}
  428. def test_returns_empty_when_no_header(self):
  429. assert _parse_3mf_gcode_header("G28\nG1 X0\n") == {}
  430. class TestPlaceholderHelper:
  431. """Direct tests for `_substitute_placeholders`."""
  432. def test_substitutes_known_keys(self):
  433. assert _substitute_placeholders("Z={a} F={b}", {"a": "10", "b": "600"}) == "Z=10 F=600"
  434. def test_alias_resolves_to_underlying_key(self):
  435. assert _substitute_placeholders("Z={max_layer_z}", {"max_z_height": "16.00"}) == "Z=16.00"
  436. def test_unknown_left_verbatim(self):
  437. assert _substitute_placeholders("{nope}", {}) == "{nope}"
  438. class TestStartMarkerHelper:
  439. """Direct tests for `_inject_start_at_marker`."""
  440. def test_inserts_before_marker_line(self):
  441. content = "first\nsecond\n; MACHINE_START_GCODE_END\ntail\n"
  442. result = _inject_start_at_marker(content, "INJECTED")
  443. assert result == "first\nsecond\nINJECTED\n; MACHINE_START_GCODE_END\ntail\n"
  444. def test_marker_at_start_of_file(self):
  445. content = "; MACHINE_START_GCODE_END\nrest\n"
  446. result = _inject_start_at_marker(content, "INJECTED")
  447. assert result == "INJECTED\n; MACHINE_START_GCODE_END\nrest\n"
  448. def test_missing_marker_falls_back_to_prepend(self):
  449. content = "G28\nG1 X0\n"
  450. result = _inject_start_at_marker(content, "INJECTED")
  451. assert result == "INJECTED\nG28\nG1 X0\n"