| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117 |
- """A slice with no printer start G-code must not become a print (#2838).
- The start block is where a Bambu printer's AMS load (``M620``) and its
- preparation-stage announcements (``M1002 gcode_claim_action``) live. Sliced
- without it, a job dispatches and looks alive — bed at temperature, toolhead
- moving — while extruding nothing and reporting no stage, which is
- indistinguishable from a print that simply has not started yet.
- The defect that produced such files was in the sidecar: Bambuddy sends a
- bundled preset by name, and a resolver that walks only ``inherits`` never
- finds the companion file holding the real start G-code, falling through to a
- 577-character generic stub. Bambuddy cannot see that resolution happen, so
- this checks the one thing it can see — the bytes that came back.
- """
- import io
- import json
- import zipfile
- import pytest
- from backend.app.services.slice_output_check import (
- missing_start_gcode_message,
- start_gcode_is_missing,
- )
- pytestmark = pytest.mark.unit
- # Abridged from `fdm_machine_common`, the root template every Bambu machine
- # preset falls through to when the companion is not read. What matters is
- # what it lacks.
- GENERIC = "M17 X1.2 Y1.2 Z0.75\nG28 X\nM104 S140\nG29.2 S0\n"
- REAL = "M1002 gcode_claim_action : 1\nM620 M\nM620.10 A0 F74.8347 H0.4 C\nM1002 gcode_claim_action : 14\n"
- def _3mf(settings: dict | None, *, valid_zip: bool = True) -> bytes:
- if not valid_zip:
- return b"not a zip file at all"
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- archive.writestr("Metadata/plate_1.gcode", "; sliced\nG1 X0 Y0\n")
- if settings is not None:
- archive.writestr("Metadata/project_settings.config", json.dumps(settings))
- return buffer.getvalue()
- class TestTheDefect:
- def test_the_generic_fallback_is_caught(self):
- assert start_gcode_is_missing(_3mf({"machine_start_gcode": GENERIC}), export_3mf=True)
- def test_a_real_start_block_passes(self):
- assert not start_gcode_is_missing(_3mf({"machine_start_gcode": REAL}), export_3mf=True)
- def test_the_value_may_be_a_list(self):
- """Slicer config values arrive as a bare string or a one-element list
- depending on the setting's declared type; both shapes are real."""
- assert not start_gcode_is_missing(_3mf({"machine_start_gcode": [REAL]}), export_3mf=True)
- assert start_gcode_is_missing(_3mf({"machine_start_gcode": [GENERIC]}), export_3mf=True)
- def test_an_empty_start_block_is_caught(self):
- assert start_gcode_is_missing(_3mf({"machine_start_gcode": ""}), export_3mf=True)
- class TestPlainGcodeExport:
- """Not every slice exports a 3MF, and a raw .gcode file has no config to
- read — the emitted text is all there is."""
- def test_a_real_start_block_passes(self):
- assert not start_gcode_is_missing(f"; header\n{REAL}G1 X0\n".encode(), export_3mf=False)
- def test_the_generic_fallback_is_caught(self):
- assert start_gcode_is_missing(f"; header\n{GENERIC}G1 X0\n".encode(), export_3mf=False)
- def test_undecodable_bytes_do_not_crash_it(self):
- """Thumbnails and comments can carry anything; the marker is ASCII."""
- assert not start_gcode_is_missing(b"\xff\xfe binary junk " + REAL.encode(), export_3mf=False)
- class TestItDeclinesToJudgeWhatItCannotRead:
- """Blocking a slice is a strong action. Anything this check cannot settle
- has to pass — the caller knows no more than it does, and refusing an
- unusual-but-fine file would be worse than the defect it guards against."""
- def test_an_unreadable_archive_passes(self):
- assert not start_gcode_is_missing(_3mf(None, valid_zip=False), export_3mf=True)
- def test_an_archive_without_the_config_passes(self):
- assert not start_gcode_is_missing(_3mf(None), export_3mf=True)
- def test_a_config_without_the_key_passes(self):
- """A slicer that does not write the key at all is not evidence that
- the printer will get no start G-code."""
- assert not start_gcode_is_missing(_3mf({"layer_height": "0.2"}), export_3mf=True)
- def test_a_malformed_config_passes(self):
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- archive.writestr("Metadata/project_settings.config", "{ not json")
- assert not start_gcode_is_missing(buffer.getvalue(), export_3mf=True)
- def test_empty_content_passes(self):
- assert not start_gcode_is_missing(b"", export_3mf=True)
- assert not start_gcode_is_missing(b"", export_3mf=False)
- class TestTheMessage:
- def test_it_names_the_preset_and_the_actual_fix(self):
- message = missing_start_gcode_message("Bambu Lab X2D 0.4 nozzle")
- assert "Bambu Lab X2D 0.4 nozzle" in message
- # The fix is a sidecar image, not anything the user can change in
- # Bambuddy — saying so is the whole point of failing loudly.
- assert "sidecar" in message
- def test_it_says_the_file_was_not_kept(self):
- assert "not saved" in missing_start_gcode_message("Bambu Lab P1S 0.4 nozzle")
|