mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
118 lines
5.0 KiB
Python
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")
|