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.
149 lines
6.0 KiB
Python
149 lines
6.0 KiB
Python
"""``_run_slicer_with_fallback`` refuses a start-G-code-less slice (#2838).
|
|
|
|
The check itself is pinned in ``test_slice_output_check_2838.py``. This covers
|
|
where it is wired: which slices it judges and which it lets past. Getting that
|
|
scope wrong in either direction is worse than the defect — too narrow and the
|
|
in-air print still ships, too wide and a user's own printer profile with a
|
|
hand-written start block stops slicing.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from backend.app.api.routes.library import _run_slicer_with_fallback
|
|
from backend.app.schemas.slicer import PresetRef, SliceRequest
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
GENERIC_START = "M17 X1.2 Y1.2 Z0.75\nG28 X\nM104 S140\n"
|
|
REAL_START = "M1002 gcode_claim_action : 1\nM620 M\nM620.10 A0 F74.8347 H0.4 C\n"
|
|
|
|
|
|
def _sliced_3mf(start_gcode: str) -> bytes:
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w") as archive:
|
|
archive.writestr("3D/3dmodel.model", "<model/>")
|
|
archive.writestr(
|
|
"Metadata/project_settings.config",
|
|
json.dumps({"machine_start_gcode": start_gcode}),
|
|
)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _source_3mf() -> bytes:
|
|
"""A source file complete enough for the wrapper's 3MF pre-processing.
|
|
|
|
The embedded-settings fallback is 3MF-only — there is nothing for an STL
|
|
to fall back *to* — so that case cannot be driven with a plain model.
|
|
"""
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
archive.writestr("3D/3dmodel.model", "<model/>")
|
|
archive.writestr("Metadata/project_settings.config", json.dumps({"layer_height": "0.2"}))
|
|
archive.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
|
|
archive.writestr(
|
|
"Metadata/slice_info.config",
|
|
"<config><plate><metadata key='index' value='1'/></plate></config>",
|
|
)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def _request(printer_source: str) -> SliceRequest:
|
|
return SliceRequest(
|
|
printer_preset=PresetRef(source=printer_source, id="Bambu Lab X2D 0.4 nozzle"),
|
|
process_preset=PresetRef(source="standard", id="0.20mm Standard @BBL X2D"),
|
|
filament_presets=[PresetRef(source="standard", id="Bambu PLA Basic @BBL X2D")],
|
|
export_3mf=True,
|
|
)
|
|
|
|
|
|
async def _run(
|
|
request: SliceRequest,
|
|
*,
|
|
start_gcode: str,
|
|
embedded_fallback: bool = False,
|
|
):
|
|
"""Drive the wrapper with a sidecar that returns exactly these bytes."""
|
|
from backend.app.services import slicer_api as slicer_api_module
|
|
|
|
result = slicer_api_module.SliceResult(
|
|
content=_sliced_3mf(start_gcode),
|
|
print_time_seconds=600,
|
|
filament_used_g=12.0,
|
|
filament_used_mm=4000.0,
|
|
)
|
|
|
|
service = MagicMock()
|
|
service.close = AsyncMock()
|
|
if embedded_fallback:
|
|
# The real fallback: the CLI dies on the --load-settings path, so the
|
|
# slice is re-run against the settings baked into the source file.
|
|
# A generic failure on purpose — a message that reads as a content
|
|
# rejection is surfaced instead of retried.
|
|
service.slice_with_profiles = AsyncMock(side_effect=slicer_api_module.SlicerApiServerError("boom"))
|
|
service.slice_without_profiles = AsyncMock(return_value=result)
|
|
else:
|
|
service.slice_with_profiles = AsyncMock(return_value=result)
|
|
service.slice_without_profiles = AsyncMock()
|
|
|
|
async def _setting(_db, key):
|
|
return {"preferred_slicer": "bambu_studio", "bambu_studio_api_url": "http://sidecar:3000"}.get(key)
|
|
|
|
with (
|
|
patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(side_effect=_setting)),
|
|
patch(
|
|
"backend.app.services.preset_resolver.resolve_preset_ref",
|
|
new=AsyncMock(return_value=json.dumps({"name": "x", "from": "system", "type": "machine"})),
|
|
),
|
|
patch.object(slicer_api_module, "SlicerApiService", return_value=service),
|
|
patch.object(slicer_api_module, "get_stall_timeout_seconds", new=AsyncMock(return_value=60.0)),
|
|
):
|
|
return await _run_slicer_with_fallback(
|
|
SimpleNamespace(get=AsyncMock(return_value=None)),
|
|
model_bytes=_source_3mf() if embedded_fallback else b"solid cube\nendsolid cube\n",
|
|
model_filename="cube.3mf" if embedded_fallback else "cube.stl",
|
|
request=request,
|
|
current_user_id=None,
|
|
)
|
|
|
|
|
|
class TestItRefusesTheDefect:
|
|
async def test_a_standard_preset_with_no_start_gcode_is_refused(self):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _run(_request("standard"), start_gcode=GENERIC_START)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert "Bambu Lab X2D 0.4 nozzle" in exc.value.detail
|
|
assert "sidecar" in exc.value.detail
|
|
|
|
async def test_the_same_slice_with_real_start_gcode_goes_through(self):
|
|
result, used_embedded = await _run(_request("standard"), start_gcode=REAL_START)
|
|
|
|
assert used_embedded is False
|
|
assert result.print_time_seconds == 600
|
|
|
|
|
|
class TestItDoesNotJudgeProfilesBambuddyDidNotResolve:
|
|
"""The bundle is what makes the absence conclusive. Outside it, the start
|
|
block is the user's to author and an empty one may well be deliberate."""
|
|
|
|
@pytest.mark.parametrize("source", ["local", "cloud", "orca_cloud"])
|
|
async def test_other_tiers_slice_normally(self, source):
|
|
result, _ = await _run(_request(source), start_gcode=GENERIC_START)
|
|
|
|
assert result.print_time_seconds == 600
|
|
|
|
async def test_the_embedded_settings_fallback_is_left_alone(self):
|
|
"""That path prints the source file's own settings — the preset we
|
|
picked was never applied, so it cannot be the thing at fault."""
|
|
result, used_embedded = await _run(_request("standard"), start_gcode=GENERIC_START, embedded_fallback=True)
|
|
|
|
assert used_embedded is True
|
|
assert result.print_time_seconds == 600
|