test_slice_output_check_2838.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. """A slice with no printer start G-code must not become a print (#2838).
  2. The start block is where a Bambu printer's AMS load (``M620``) and its
  3. preparation-stage announcements (``M1002 gcode_claim_action``) live. Sliced
  4. without it, a job dispatches and looks alive — bed at temperature, toolhead
  5. moving — while extruding nothing and reporting no stage, which is
  6. indistinguishable from a print that simply has not started yet.
  7. The defect that produced such files was in the sidecar: Bambuddy sends a
  8. bundled preset by name, and a resolver that walks only ``inherits`` never
  9. finds the companion file holding the real start G-code, falling through to a
  10. 577-character generic stub. Bambuddy cannot see that resolution happen, so
  11. this checks the one thing it can see — the bytes that came back.
  12. """
  13. import io
  14. import json
  15. import zipfile
  16. import pytest
  17. from backend.app.services.slice_output_check import (
  18. missing_start_gcode_message,
  19. start_gcode_is_missing,
  20. )
  21. pytestmark = pytest.mark.unit
  22. # Abridged from `fdm_machine_common`, the root template every Bambu machine
  23. # preset falls through to when the companion is not read. What matters is
  24. # what it lacks.
  25. GENERIC = "M17 X1.2 Y1.2 Z0.75\nG28 X\nM104 S140\nG29.2 S0\n"
  26. REAL = "M1002 gcode_claim_action : 1\nM620 M\nM620.10 A0 F74.8347 H0.4 C\nM1002 gcode_claim_action : 14\n"
  27. def _3mf(settings: dict | None, *, valid_zip: bool = True) -> bytes:
  28. if not valid_zip:
  29. return b"not a zip file at all"
  30. buffer = io.BytesIO()
  31. with zipfile.ZipFile(buffer, "w") as archive:
  32. archive.writestr("Metadata/plate_1.gcode", "; sliced\nG1 X0 Y0\n")
  33. if settings is not None:
  34. archive.writestr("Metadata/project_settings.config", json.dumps(settings))
  35. return buffer.getvalue()
  36. class TestTheDefect:
  37. def test_the_generic_fallback_is_caught(self):
  38. assert start_gcode_is_missing(_3mf({"machine_start_gcode": GENERIC}), export_3mf=True)
  39. def test_a_real_start_block_passes(self):
  40. assert not start_gcode_is_missing(_3mf({"machine_start_gcode": REAL}), export_3mf=True)
  41. def test_the_value_may_be_a_list(self):
  42. """Slicer config values arrive as a bare string or a one-element list
  43. depending on the setting's declared type; both shapes are real."""
  44. assert not start_gcode_is_missing(_3mf({"machine_start_gcode": [REAL]}), export_3mf=True)
  45. assert start_gcode_is_missing(_3mf({"machine_start_gcode": [GENERIC]}), export_3mf=True)
  46. def test_an_empty_start_block_is_caught(self):
  47. assert start_gcode_is_missing(_3mf({"machine_start_gcode": ""}), export_3mf=True)
  48. class TestPlainGcodeExport:
  49. """Not every slice exports a 3MF, and a raw .gcode file has no config to
  50. read — the emitted text is all there is."""
  51. def test_a_real_start_block_passes(self):
  52. assert not start_gcode_is_missing(f"; header\n{REAL}G1 X0\n".encode(), export_3mf=False)
  53. def test_the_generic_fallback_is_caught(self):
  54. assert start_gcode_is_missing(f"; header\n{GENERIC}G1 X0\n".encode(), export_3mf=False)
  55. def test_undecodable_bytes_do_not_crash_it(self):
  56. """Thumbnails and comments can carry anything; the marker is ASCII."""
  57. assert not start_gcode_is_missing(b"\xff\xfe binary junk " + REAL.encode(), export_3mf=False)
  58. class TestItDeclinesToJudgeWhatItCannotRead:
  59. """Blocking a slice is a strong action. Anything this check cannot settle
  60. has to pass — the caller knows no more than it does, and refusing an
  61. unusual-but-fine file would be worse than the defect it guards against."""
  62. def test_an_unreadable_archive_passes(self):
  63. assert not start_gcode_is_missing(_3mf(None, valid_zip=False), export_3mf=True)
  64. def test_an_archive_without_the_config_passes(self):
  65. assert not start_gcode_is_missing(_3mf(None), export_3mf=True)
  66. def test_a_config_without_the_key_passes(self):
  67. """A slicer that does not write the key at all is not evidence that
  68. the printer will get no start G-code."""
  69. assert not start_gcode_is_missing(_3mf({"layer_height": "0.2"}), export_3mf=True)
  70. def test_a_malformed_config_passes(self):
  71. buffer = io.BytesIO()
  72. with zipfile.ZipFile(buffer, "w") as archive:
  73. archive.writestr("Metadata/project_settings.config", "{ not json")
  74. assert not start_gcode_is_missing(buffer.getvalue(), export_3mf=True)
  75. def test_empty_content_passes(self):
  76. assert not start_gcode_is_missing(b"", export_3mf=True)
  77. assert not start_gcode_is_missing(b"", export_3mf=False)
  78. class TestTheMessage:
  79. def test_it_names_the_preset_and_the_actual_fix(self):
  80. message = missing_start_gcode_message("Bambu Lab X2D 0.4 nozzle")
  81. assert "Bambu Lab X2D 0.4 nozzle" in message
  82. # The fix is a sidecar image, not anything the user can change in
  83. # Bambuddy — saying so is the whole point of failing loudly.
  84. assert "sidecar" in message
  85. def test_it_says_the_file_was_not_kept(self):
  86. assert "not saved" in missing_start_gcode_message("Bambu Lab P1S 0.4 nozzle")