| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- """``_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
|