test_slice_backstop_wiring_2838.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. """``_run_slicer_with_fallback`` refuses a start-G-code-less slice (#2838).
  2. The check itself is pinned in ``test_slice_output_check_2838.py``. This covers
  3. where it is wired: which slices it judges and which it lets past. Getting that
  4. scope wrong in either direction is worse than the defect — too narrow and the
  5. in-air print still ships, too wide and a user's own printer profile with a
  6. hand-written start block stops slicing.
  7. """
  8. import io
  9. import json
  10. import zipfile
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, MagicMock, patch
  13. import pytest
  14. from fastapi import HTTPException
  15. from backend.app.api.routes.library import _run_slicer_with_fallback
  16. from backend.app.schemas.slicer import PresetRef, SliceRequest
  17. pytestmark = pytest.mark.unit
  18. GENERIC_START = "M17 X1.2 Y1.2 Z0.75\nG28 X\nM104 S140\n"
  19. REAL_START = "M1002 gcode_claim_action : 1\nM620 M\nM620.10 A0 F74.8347 H0.4 C\n"
  20. def _sliced_3mf(start_gcode: str) -> bytes:
  21. buffer = io.BytesIO()
  22. with zipfile.ZipFile(buffer, "w") as archive:
  23. archive.writestr("3D/3dmodel.model", "<model/>")
  24. archive.writestr(
  25. "Metadata/project_settings.config",
  26. json.dumps({"machine_start_gcode": start_gcode}),
  27. )
  28. return buffer.getvalue()
  29. def _source_3mf() -> bytes:
  30. """A source file complete enough for the wrapper's 3MF pre-processing.
  31. The embedded-settings fallback is 3MF-only — there is nothing for an STL
  32. to fall back *to* — so that case cannot be driven with a plain model.
  33. """
  34. buffer = io.BytesIO()
  35. with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
  36. archive.writestr("3D/3dmodel.model", "<model/>")
  37. archive.writestr("Metadata/project_settings.config", json.dumps({"layer_height": "0.2"}))
  38. archive.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
  39. archive.writestr(
  40. "Metadata/slice_info.config",
  41. "<config><plate><metadata key='index' value='1'/></plate></config>",
  42. )
  43. return buffer.getvalue()
  44. def _request(printer_source: str) -> SliceRequest:
  45. return SliceRequest(
  46. printer_preset=PresetRef(source=printer_source, id="Bambu Lab X2D 0.4 nozzle"),
  47. process_preset=PresetRef(source="standard", id="0.20mm Standard @BBL X2D"),
  48. filament_presets=[PresetRef(source="standard", id="Bambu PLA Basic @BBL X2D")],
  49. export_3mf=True,
  50. )
  51. async def _run(
  52. request: SliceRequest,
  53. *,
  54. start_gcode: str,
  55. embedded_fallback: bool = False,
  56. ):
  57. """Drive the wrapper with a sidecar that returns exactly these bytes."""
  58. from backend.app.services import slicer_api as slicer_api_module
  59. result = slicer_api_module.SliceResult(
  60. content=_sliced_3mf(start_gcode),
  61. print_time_seconds=600,
  62. filament_used_g=12.0,
  63. filament_used_mm=4000.0,
  64. )
  65. service = MagicMock()
  66. service.close = AsyncMock()
  67. if embedded_fallback:
  68. # The real fallback: the CLI dies on the --load-settings path, so the
  69. # slice is re-run against the settings baked into the source file.
  70. # A generic failure on purpose — a message that reads as a content
  71. # rejection is surfaced instead of retried.
  72. service.slice_with_profiles = AsyncMock(side_effect=slicer_api_module.SlicerApiServerError("boom"))
  73. service.slice_without_profiles = AsyncMock(return_value=result)
  74. else:
  75. service.slice_with_profiles = AsyncMock(return_value=result)
  76. service.slice_without_profiles = AsyncMock()
  77. async def _setting(_db, key):
  78. return {"preferred_slicer": "bambu_studio", "bambu_studio_api_url": "http://sidecar:3000"}.get(key)
  79. with (
  80. patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(side_effect=_setting)),
  81. patch(
  82. "backend.app.services.preset_resolver.resolve_preset_ref",
  83. new=AsyncMock(return_value=json.dumps({"name": "x", "from": "system", "type": "machine"})),
  84. ),
  85. patch.object(slicer_api_module, "SlicerApiService", return_value=service),
  86. patch.object(slicer_api_module, "get_stall_timeout_seconds", new=AsyncMock(return_value=60.0)),
  87. ):
  88. return await _run_slicer_with_fallback(
  89. SimpleNamespace(get=AsyncMock(return_value=None)),
  90. model_bytes=_source_3mf() if embedded_fallback else b"solid cube\nendsolid cube\n",
  91. model_filename="cube.3mf" if embedded_fallback else "cube.stl",
  92. request=request,
  93. current_user_id=None,
  94. )
  95. class TestItRefusesTheDefect:
  96. async def test_a_standard_preset_with_no_start_gcode_is_refused(self):
  97. with pytest.raises(HTTPException) as exc:
  98. await _run(_request("standard"), start_gcode=GENERIC_START)
  99. assert exc.value.status_code == 502
  100. assert "Bambu Lab X2D 0.4 nozzle" in exc.value.detail
  101. assert "sidecar" in exc.value.detail
  102. async def test_the_same_slice_with_real_start_gcode_goes_through(self):
  103. result, used_embedded = await _run(_request("standard"), start_gcode=REAL_START)
  104. assert used_embedded is False
  105. assert result.print_time_seconds == 600
  106. class TestItDoesNotJudgeProfilesBambuddyDidNotResolve:
  107. """The bundle is what makes the absence conclusive. Outside it, the start
  108. block is the user's to author and an empty one may well be deliberate."""
  109. @pytest.mark.parametrize("source", ["local", "cloud", "orca_cloud"])
  110. async def test_other_tiers_slice_normally(self, source):
  111. result, _ = await _run(_request(source), start_gcode=GENERIC_START)
  112. assert result.print_time_seconds == 600
  113. async def test_the_embedded_settings_fallback_is_left_alone(self):
  114. """That path prints the source file's own settings — the preset we
  115. picked was never applied, so it cannot be the thing at fault."""
  116. result, used_embedded = await _run(_request("standard"), start_gcode=GENERIC_START, embedded_fallback=True)
  117. assert used_embedded is True
  118. assert result.print_time_seconds == 600