Files
bambuddy/backend/tests/unit/test_slice_output_check_2838.py
maziggy f3b6a503bd fix(profiles): read the companion files that hold a preset's real gcode
A bundled preset can keep a setting in `<preset> template <key>.json`, a
file the preset itself does not reference -- the desktop slicer finds it
by name. Walking only `inherits` never reached it, so every one of the 56
instantiable BBL machine presets resolved `machine_start_gcode` to the
577-character generic block on fdm_machine_common instead of its own
6.5-21 KB one. That block holds the M620 AMS load and the M1002
gcode_claim_action calls, so a print sliced from it heats the bed, moves
the toolhead and extrudes nothing (bambuddy#2838).

Companions are now folded into each ancestor as the chain is walked, at
that ancestor's precedence, so a caller's own value still wins and the
0.2/0.6/0.8 variants reach their 0.4 sibling's companion. They are found
by listing rather than by a fixed set of keys.

Covered against the shipped bundle, not fixtures: a new e2e spec resolves
all 56 presets inside the image and fails on any that still lands on the
generic block.
2026-08-15 11:37:01 +02:00

118 lines
5.0 KiB
Python

"""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")