test_gcode_injection.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """Unit tests for G-code injection into 3MF files (#422)."""
  2. import tempfile
  3. import zipfile
  4. from pathlib import Path
  5. import pytest
  6. from backend.app.utils.threemf_tools import inject_gcode_into_3mf
  7. def _make_temp_path(suffix=".3mf") -> Path:
  8. """Create a temp file path without leaving it open (avoids SIM115)."""
  9. fd, name = tempfile.mkstemp(suffix=suffix)
  10. import os
  11. os.close(fd)
  12. return Path(name)
  13. def _make_test_3mf(gcode_content: str = "G28\nG1 X0 Y0\nM400\n", plate_id: int = 1) -> Path:
  14. """Create a minimal 3MF file with embedded G-code for testing."""
  15. tmp_path = _make_temp_path()
  16. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
  17. zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode_content)
  18. zf.writestr("Metadata/slice_info.config", "<config></config>")
  19. zf.writestr("3D/3dmodel.model", "<model></model>")
  20. return tmp_path
  21. class TestInjectGcodeInto3mf:
  22. """Tests for inject_gcode_into_3mf()."""
  23. def test_inject_start_gcode(self):
  24. """Start G-code is prepended before the original content."""
  25. source = _make_test_3mf("G28\nM400\n")
  26. try:
  27. result = inject_gcode_into_3mf(source, 1, "M117 Start\nG92 E0", None)
  28. assert result is not None
  29. with zipfile.ZipFile(result, "r") as zf:
  30. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  31. assert gcode.startswith("M117 Start\nG92 E0\n")
  32. assert "G28\nM400\n" in gcode
  33. finally:
  34. source.unlink(missing_ok=True)
  35. if result:
  36. result.unlink(missing_ok=True)
  37. def test_inject_end_gcode(self):
  38. """End G-code is appended after the original content."""
  39. source = _make_test_3mf("G28\nM400")
  40. try:
  41. result = inject_gcode_into_3mf(source, 1, None, "M104 S0\nG28 X")
  42. assert result is not None
  43. with zipfile.ZipFile(result, "r") as zf:
  44. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  45. assert gcode.endswith("M104 S0\nG28 X\n")
  46. assert gcode.startswith("G28\nM400")
  47. finally:
  48. source.unlink(missing_ok=True)
  49. if result:
  50. result.unlink(missing_ok=True)
  51. def test_inject_both_start_and_end(self):
  52. """Both start and end G-code are injected."""
  53. source = _make_test_3mf("G28\n")
  54. try:
  55. result = inject_gcode_into_3mf(source, 1, "; START", "; END")
  56. assert result is not None
  57. with zipfile.ZipFile(result, "r") as zf:
  58. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  59. assert gcode.startswith("; START\n")
  60. assert gcode.endswith("; END\n")
  61. assert "G28" in gcode
  62. finally:
  63. source.unlink(missing_ok=True)
  64. if result:
  65. result.unlink(missing_ok=True)
  66. def test_no_injection_returns_none(self):
  67. """Returns None when both start and end are None."""
  68. source = _make_test_3mf()
  69. try:
  70. result = inject_gcode_into_3mf(source, 1, None, None)
  71. assert result is None
  72. finally:
  73. source.unlink(missing_ok=True)
  74. def test_empty_strings_returns_none(self):
  75. """Returns None when both start and end are empty strings."""
  76. source = _make_test_3mf()
  77. try:
  78. result = inject_gcode_into_3mf(source, 1, "", "")
  79. assert result is None
  80. finally:
  81. source.unlink(missing_ok=True)
  82. def test_plate_id_selection(self):
  83. """Injects into the correct plate's G-code file."""
  84. source = _make_temp_path()
  85. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  86. zf.writestr("Metadata/plate_1.gcode", "PLATE1\n")
  87. zf.writestr("Metadata/plate_2.gcode", "PLATE2\n")
  88. try:
  89. result = inject_gcode_into_3mf(source, 2, "; INJECTED", None)
  90. assert result is not None
  91. with zipfile.ZipFile(result, "r") as zf:
  92. plate1 = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  93. plate2 = zf.read("Metadata/plate_2.gcode").decode("utf-8")
  94. # Only plate 2 should be modified
  95. assert plate1 == "PLATE1\n"
  96. assert plate2.startswith("; INJECTED\n")
  97. finally:
  98. source.unlink(missing_ok=True)
  99. if result:
  100. result.unlink(missing_ok=True)
  101. def test_preserves_other_files(self):
  102. """Non-gcode files in the 3MF are preserved unchanged."""
  103. source = _make_test_3mf()
  104. try:
  105. result = inject_gcode_into_3mf(source, 1, "; START", None)
  106. assert result is not None
  107. with zipfile.ZipFile(result, "r") as zf:
  108. names = zf.namelist()
  109. assert "Metadata/slice_info.config" in names
  110. assert "3D/3dmodel.model" in names
  111. config = zf.read("Metadata/slice_info.config").decode("utf-8")
  112. assert config == "<config></config>"
  113. finally:
  114. source.unlink(missing_ok=True)
  115. if result:
  116. result.unlink(missing_ok=True)
  117. def test_no_gcode_file_returns_none(self):
  118. """Returns None when the 3MF has no gcode files."""
  119. source = _make_temp_path()
  120. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  121. zf.writestr("3D/3dmodel.model", "<model></model>")
  122. try:
  123. result = inject_gcode_into_3mf(source, 1, "; START", None)
  124. assert result is None
  125. finally:
  126. source.unlink(missing_ok=True)
  127. def test_invalid_file_returns_none(self):
  128. """Returns None for a non-ZIP file."""
  129. source = _make_temp_path()
  130. source.write_bytes(b"not a zip file")
  131. try:
  132. result = inject_gcode_into_3mf(source, 1, "; START", None)
  133. assert result is None
  134. finally:
  135. source.unlink(missing_ok=True)
  136. def test_fallback_to_first_gcode(self):
  137. """Falls back to first gcode file when plate-specific not found."""
  138. source = _make_temp_path()
  139. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  140. zf.writestr("Metadata/plate_1.gcode", "ORIGINAL\n")
  141. try:
  142. # Request plate 5 which doesn't exist — should fall back to plate_1
  143. result = inject_gcode_into_3mf(source, 5, "; INJECTED", None)
  144. assert result is not None
  145. with zipfile.ZipFile(result, "r") as zf:
  146. gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  147. assert gcode.startswith("; INJECTED\n")
  148. finally:
  149. source.unlink(missing_ok=True)
  150. if result:
  151. result.unlink(missing_ok=True)
  152. def test_original_file_unchanged(self):
  153. """The source 3MF is never modified."""
  154. source = _make_test_3mf("ORIGINAL\n")
  155. try:
  156. result = inject_gcode_into_3mf(source, 1, "; START", "; END")
  157. assert result is not None
  158. # Verify original is untouched
  159. with zipfile.ZipFile(source, "r") as zf:
  160. original = zf.read("Metadata/plate_1.gcode").decode("utf-8")
  161. assert original == "ORIGINAL\n"
  162. finally:
  163. source.unlink(missing_ok=True)
  164. if result:
  165. result.unlink(missing_ok=True)