test_gcode_injection.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. """Unit tests for G-code injection into 3MF files (#422)."""
  2. import tempfile
  3. import zipfile
  4. from pathlib import Path
  5. from backend.app.utils.threemf_tools import (
  6. _inject_start_at_marker,
  7. _parse_3mf_gcode_header,
  8. _substitute_placeholders,
  9. inject_gcode_into_3mf,
  10. )
  11. def _make_temp_path(suffix=".3mf") -> Path:
  12. """Create a temp file path without leaving it open (avoids SIM115)."""
  13. fd, name = tempfile.mkstemp(suffix=suffix)
  14. import os
  15. os.close(fd)
  16. return Path(name)
  17. def _make_test_3mf(gcode_content: str = "G28\nG1 X0 Y0\nM400\n", plate_id: int = 1) -> Path:
  18. """Create a minimal 3MF file with embedded G-code for testing."""
  19. tmp_path = _make_temp_path()
  20. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
  21. zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode_content)
  22. zf.writestr("Metadata/slice_info.config", "<config></config>")
  23. zf.writestr("3D/3dmodel.model", "<model></model>")
  24. return tmp_path
  25. class TestInjectGcodeInto3mf:
  26. """Tests for inject_gcode_into_3mf()."""
  27. def test_inject_start_gcode(self):
  28. """Start G-code is prepended before the original content."""
  29. source = _make_test_3mf("G28\nM400\n")
  30. try:
  31. result = inject_gcode_into_3mf(source, 1, "M117 Start\nG92 E0", None)
  32. assert result is not None
  33. with zipfile.ZipFile(result, "r") as zf:
  34. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  35. assert gcode.startswith("M117 Start\nG92 E0\n")
  36. assert "G28\nM400\n" in gcode
  37. finally:
  38. source.unlink(missing_ok=True)
  39. if result:
  40. result.unlink(missing_ok=True)
  41. def test_inject_end_gcode(self):
  42. """End G-code is appended after the original content."""
  43. source = _make_test_3mf("G28\nM400")
  44. try:
  45. result = inject_gcode_into_3mf(source, 1, None, "M104 S0\nG28 X")
  46. assert result is not None
  47. with zipfile.ZipFile(result, "r") as zf:
  48. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  49. assert gcode.endswith("M104 S0\nG28 X\n")
  50. assert gcode.startswith("G28\nM400")
  51. finally:
  52. source.unlink(missing_ok=True)
  53. if result:
  54. result.unlink(missing_ok=True)
  55. def test_inject_both_start_and_end(self):
  56. """Both start and end G-code are injected."""
  57. source = _make_test_3mf("G28\n")
  58. try:
  59. result = inject_gcode_into_3mf(source, 1, "; START", "; END")
  60. assert result is not None
  61. with zipfile.ZipFile(result, "r") as zf:
  62. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  63. assert gcode.startswith("; START\n")
  64. assert gcode.endswith("; END\n")
  65. assert "G28" in gcode
  66. finally:
  67. source.unlink(missing_ok=True)
  68. if result:
  69. result.unlink(missing_ok=True)
  70. def test_no_injection_returns_none(self):
  71. """Returns None when both start and end are None."""
  72. source = _make_test_3mf()
  73. try:
  74. result = inject_gcode_into_3mf(source, 1, None, None)
  75. assert result is None
  76. finally:
  77. source.unlink(missing_ok=True)
  78. def test_empty_strings_returns_none(self):
  79. """Returns None when both start and end are empty strings."""
  80. source = _make_test_3mf()
  81. try:
  82. result = inject_gcode_into_3mf(source, 1, "", "")
  83. assert result is None
  84. finally:
  85. source.unlink(missing_ok=True)
  86. def test_plate_id_selection(self):
  87. """Injects into the correct plate's G-code file."""
  88. source = _make_temp_path()
  89. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  90. zf.writestr("Metadata/plate_1.gcode", "PLATE1\n")
  91. zf.writestr("Metadata/plate_2.gcode", "PLATE2\n")
  92. try:
  93. result = inject_gcode_into_3mf(source, 2, "; INJECTED", None)
  94. assert result is not None
  95. with zipfile.ZipFile(result, "r") as zf:
  96. plate1 = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  97. plate2 = zf.read("Metadata/plate_2.gcode").decode("utf-8")
  98. # Only plate 2 should be modified
  99. assert plate1 == "PLATE1\n"
  100. assert plate2.startswith("; INJECTED\n")
  101. finally:
  102. source.unlink(missing_ok=True)
  103. if result:
  104. result.unlink(missing_ok=True)
  105. def test_preserves_other_files(self):
  106. """Non-gcode files in the 3MF are preserved unchanged."""
  107. source = _make_test_3mf()
  108. try:
  109. result = inject_gcode_into_3mf(source, 1, "; START", None)
  110. assert result is not None
  111. with zipfile.ZipFile(result, "r") as zf:
  112. names = zf.namelist()
  113. assert "Metadata/slice_info.config" in names
  114. assert "3D/3dmodel.model" in names
  115. config = zf.read("Metadata/slice_info.config").decode("utf-8")
  116. assert config == "<config></config>"
  117. finally:
  118. source.unlink(missing_ok=True)
  119. if result:
  120. result.unlink(missing_ok=True)
  121. def test_no_gcode_file_returns_none(self):
  122. """Returns None when the 3MF has no gcode files."""
  123. source = _make_temp_path()
  124. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  125. zf.writestr("3D/3dmodel.model", "<model></model>")
  126. try:
  127. result = inject_gcode_into_3mf(source, 1, "; START", None)
  128. assert result is None
  129. finally:
  130. source.unlink(missing_ok=True)
  131. def test_invalid_file_returns_none(self):
  132. """Returns None for a non-ZIP file."""
  133. source = _make_temp_path()
  134. source.write_bytes(b"not a zip file")
  135. try:
  136. result = inject_gcode_into_3mf(source, 1, "; START", None)
  137. assert result is None
  138. finally:
  139. source.unlink(missing_ok=True)
  140. def test_fallback_to_first_gcode(self):
  141. """Falls back to first gcode file when plate-specific not found."""
  142. source = _make_temp_path()
  143. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  144. zf.writestr("Metadata/plate_1.gcode", "ORIGINAL\n")
  145. try:
  146. # Request plate 5 which doesn't exist — should fall back to plate_1
  147. result = inject_gcode_into_3mf(source, 5, "; INJECTED", None)
  148. assert result is not None
  149. with zipfile.ZipFile(result, "r") as zf:
  150. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  151. assert gcode.startswith("; INJECTED\n")
  152. finally:
  153. source.unlink(missing_ok=True)
  154. if result:
  155. result.unlink(missing_ok=True)
  156. def test_original_file_unchanged(self):
  157. """The source 3MF is never modified."""
  158. source = _make_test_3mf("ORIGINAL\n")
  159. try:
  160. result = inject_gcode_into_3mf(source, 1, "; START", "; END")
  161. assert result is not None
  162. # Verify original is untouched
  163. with zipfile.ZipFile(source, "r") as zf:
  164. original = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  165. assert original == "ORIGINAL\n"
  166. finally:
  167. source.unlink(missing_ok=True)
  168. if result:
  169. result.unlink(missing_ok=True)
  170. # Realistic Bambu / Orca header + startup block — the start-gcode marker is the
  171. # anchor point #422 reviewers (DevScarabyte, pleite) reported as the correct
  172. # injection point. Snippets injected before this should land *after* the bed
  173. # heat / homing / nozzle prime sequence, not before it.
  174. _BAMBU_GCODE_TEMPLATE = """\
  175. ; HEADER_BLOCK_START
  176. ; BambuStudio 02.06.00.51
  177. ; total layer number: 80
  178. ; total filament length [mm] : 12155.34
  179. ; total filament weight [g] : 36.55
  180. ; max_z_height: 16.00
  181. ; HEADER_BLOCK_END
  182. ; MACHINE_START_GCODE_BEGIN
  183. M104 S220 ; preheat
  184. G28 ; home
  185. M109 S220 ; wait for nozzle
  186. G92 E0 ; reset extruder
  187. ; MACHINE_START_GCODE_END
  188. G1 X10 Y10 Z0.2
  189. G1 X100 Y100 E5
  190. M104 S0
  191. """
  192. class TestStartAnchoredInjection:
  193. """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
  194. def test_start_lands_after_printer_startup(self):
  195. """Start snippet sits immediately before MACHINE_START_GCODE_END, not at file head."""
  196. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  197. try:
  198. result = inject_gcode_into_3mf(source, 1, "; SWAPMOD-START", None)
  199. assert result is not None
  200. with zipfile.ZipFile(result, "r") as zf:
  201. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  202. # Original file head is preserved — snippet does NOT prepend.
  203. assert gcode.startswith("; HEADER_BLOCK_START\n")
  204. # Snippet sits right above the marker.
  205. marker_idx = gcode.index("; MACHINE_START_GCODE_END")
  206. snippet_idx = gcode.index("; SWAPMOD-START")
  207. assert snippet_idx < marker_idx
  208. # Nothing else between snippet and marker except the trailing newline.
  209. between = gcode[snippet_idx:marker_idx]
  210. assert between == "; SWAPMOD-START\n"
  211. # Printer's own startup commands still come BEFORE the snippet.
  212. startup_idx = gcode.index("M109 S220")
  213. assert startup_idx < snippet_idx
  214. finally:
  215. source.unlink(missing_ok=True)
  216. if result:
  217. result.unlink(missing_ok=True)
  218. def test_no_marker_falls_back_to_prepend(self):
  219. """Files without MACHINE_START_GCODE_END (older slicers) keep prepend behaviour."""
  220. source = _make_test_3mf("G28\nM400\n")
  221. try:
  222. result = inject_gcode_into_3mf(source, 1, "; LEGACY-START", None)
  223. assert result is not None
  224. with zipfile.ZipFile(result, "r") as zf:
  225. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  226. assert gcode.startswith("; LEGACY-START\n")
  227. assert "G28" in gcode
  228. finally:
  229. source.unlink(missing_ok=True)
  230. if result:
  231. result.unlink(missing_ok=True)
  232. def test_end_still_appended_at_eof(self):
  233. """End g-code keeps the existing append-to-EOF behaviour even with marker present."""
  234. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  235. try:
  236. result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
  237. assert result is not None
  238. with zipfile.ZipFile(result, "r") as zf:
  239. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  240. assert gcode.endswith("; SWAPMOD-END\n")
  241. # Marker anchor is irrelevant for end snippets.
  242. assert gcode.index("; SWAPMOD-END") > gcode.index("; MACHINE_START_GCODE_END")
  243. finally:
  244. source.unlink(missing_ok=True)
  245. if result:
  246. result.unlink(missing_ok=True)
  247. class TestPlaceholderSubstitution:
  248. """Tests for #422 follow-up: {placeholder} substitution from 3MF header values."""
  249. def test_max_z_height_substituted_in_end_snippet(self):
  250. """`G1 Z{max_layer_z}` resolves to the model's actual top-layer Z (DevScarabyte safety bug)."""
  251. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  252. try:
  253. # Prusa-style alias: max_layer_z → max_z_height in the Bambu header
  254. result = inject_gcode_into_3mf(source, 1, None, "G1 Z{max_layer_z} F600")
  255. assert result is not None
  256. with zipfile.ZipFile(result, "r") as zf:
  257. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  258. # max_z_height in the template is 16.00 — the dangerous Z1 fallback is gone.
  259. assert "G1 Z16.00 F600" in gcode
  260. assert "{max_layer_z}" not in gcode
  261. finally:
  262. source.unlink(missing_ok=True)
  263. if result:
  264. result.unlink(missing_ok=True)
  265. def test_direct_header_key_lookup(self):
  266. """Snippets can reference normalised header keys directly without going through aliases."""
  267. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  268. try:
  269. result = inject_gcode_into_3mf(
  270. source, 1, None, "; layers={total_layer_number} weight={total_filament_weight}"
  271. )
  272. assert result is not None
  273. with zipfile.ZipFile(result, "r") as zf:
  274. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  275. assert "; layers=80 weight=36.55" in gcode
  276. finally:
  277. source.unlink(missing_ok=True)
  278. if result:
  279. result.unlink(missing_ok=True)
  280. def test_unknown_placeholder_left_intact(self):
  281. """A typo or unsupported placeholder is preserved verbatim instead of becoming empty."""
  282. source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
  283. try:
  284. result = inject_gcode_into_3mf(source, 1, None, "; nope={does_not_exist}")
  285. assert result is not None
  286. with zipfile.ZipFile(result, "r") as zf:
  287. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  288. assert "; nope={does_not_exist}" in gcode
  289. finally:
  290. source.unlink(missing_ok=True)
  291. if result:
  292. result.unlink(missing_ok=True)
  293. def test_no_placeholders_no_header_required(self):
  294. """Snippets without placeholders inject correctly even when the header is absent."""
  295. source = _make_test_3mf("G28\nM400\n")
  296. try:
  297. result = inject_gcode_into_3mf(source, 1, "; PLAIN", None)
  298. assert result is not None
  299. with zipfile.ZipFile(result, "r") as zf:
  300. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  301. assert gcode.startswith("; PLAIN\n")
  302. finally:
  303. source.unlink(missing_ok=True)
  304. if result:
  305. result.unlink(missing_ok=True)
  306. class TestHeaderParser:
  307. """Direct tests for `_parse_3mf_gcode_header`."""
  308. def test_parses_bambu_header_block(self):
  309. header = _parse_3mf_gcode_header(_BAMBU_GCODE_TEMPLATE)
  310. assert header["max_z_height"] == "16.00"
  311. assert header["total_layer_number"] == "80"
  312. # Units suffix is stripped from the key.
  313. assert header["total_filament_length"] == "12155.34"
  314. assert header["total_filament_weight"] == "36.55"
  315. def test_ignores_lines_outside_header_block(self):
  316. content = "; HEADER_BLOCK_START\n; key: in\n; HEADER_BLOCK_END\n; key: out\n"
  317. header = _parse_3mf_gcode_header(content)
  318. assert header == {"key": "in"}
  319. def test_returns_empty_when_no_header(self):
  320. assert _parse_3mf_gcode_header("G28\nG1 X0\n") == {}
  321. class TestPlaceholderHelper:
  322. """Direct tests for `_substitute_placeholders`."""
  323. def test_substitutes_known_keys(self):
  324. assert _substitute_placeholders("Z={a} F={b}", {"a": "10", "b": "600"}) == "Z=10 F=600"
  325. def test_alias_resolves_to_underlying_key(self):
  326. assert _substitute_placeholders("Z={max_layer_z}", {"max_z_height": "16.00"}) == "Z=16.00"
  327. def test_unknown_left_verbatim(self):
  328. assert _substitute_placeholders("{nope}", {}) == "{nope}"
  329. class TestStartMarkerHelper:
  330. """Direct tests for `_inject_start_at_marker`."""
  331. def test_inserts_before_marker_line(self):
  332. content = "first\nsecond\n; MACHINE_START_GCODE_END\ntail\n"
  333. result = _inject_start_at_marker(content, "INJECTED")
  334. assert result == "first\nsecond\nINJECTED\n; MACHINE_START_GCODE_END\ntail\n"
  335. def test_marker_at_start_of_file(self):
  336. content = "; MACHINE_START_GCODE_END\nrest\n"
  337. result = _inject_start_at_marker(content, "INJECTED")
  338. assert result == "INJECTED\n; MACHINE_START_GCODE_END\nrest\n"
  339. def test_missing_marker_falls_back_to_prepend(self):
  340. content = "G28\nG1 X0\n"
  341. result = _inject_start_at_marker(content, "INJECTED")
  342. assert result == "INJECTED\nG28\nG1 X0\n"