Files
bambuddy/backend/tests/unit/test_bed_jog.py
T
maziggy 7026a6de77 fix(printer): bed-jog "Home Z" could crash bed into toolhead on H2C/H2D/H2S/X1 (#1052)
Critical safety fix. The bed-jog dialog's "Home Z" button sent a bare
  `G28 Z` over gcode_line. On Bambu printers where the Z endstop is at
  the top (bed moves UP into it — H2C, H2D, H2S, X1 family), `G28 Z`
  skips the toolhead-park step that a full `G28` runs first, so the bed
  rises at full speed with nothing getting out of the way. The reporter
  only escaped damage because the toolhead happened to be parked on the
  purge chute.

  The /printers/{id}/home-axes endpoint and BambuClient.home_axes() now
  always send bare `G28` regardless of the axes argument, triggering the
  firmware's safe multi-step routine (park toolhead → home XY → home Z).
  The axes argument is kept for API compat but ignored; invalid values
  still return 400.

  Frontend retitles the button "Auto Home" and updates the dialog copy
  in all 7 locales so users aren't surprised when X/Y motion happens
  before Z. Parameterized regression test asserts z/xy/all all produce
  bare G28.
2026-04-20 12:56:31 +02:00

119 lines
5.9 KiB
Python

"""Unit tests for the bed-jog and home-axes endpoints (#791).
Tests:
POST /api/v1/printers/{printer_id}/bed-jog?distance=<mm>&force=<bool>
POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>
"""
from unittest.mock import MagicMock, patch
import pytest
from httpx import AsyncClient
class TestBedJogAPI:
@pytest.mark.asyncio
async def test_bed_jog_not_found(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/printers/99999/bed-jog?distance=10")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_bed_jog_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="P1")
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=0")
assert response.status_code == 400
assert "distance" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_bed_jog_too_large_rejected(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="P1")
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=500")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_bed_jog_not_connected(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="Disconnected")
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = None
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
assert response.status_code == 400
assert "not connected" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_bed_jog_send_failure(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="P1")
mock_client = MagicMock()
mock_client.send_gcode.return_value = False
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
assert response.status_code == 500
@pytest.mark.asyncio
async def test_bed_jog_success_without_force(self, async_client: AsyncClient, printer_factory):
"""When force=false the M211 guard lines must not be emitted."""
printer = await printer_factory(name="P1")
mock_client = MagicMock()
mock_client.send_gcode.return_value = True
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10&force=false")
assert response.status_code == 200
sent_gcode = mock_client.send_gcode.call_args[0][0]
assert "G91" in sent_gcode
assert "G1 Z10.00" in sent_gcode
assert "G90" in sent_gcode
assert "M211" not in sent_gcode
@pytest.mark.asyncio
async def test_bed_jog_success_with_force(self, async_client: AsyncClient, printer_factory):
"""force=true must wrap the move in M211 S0 / M211 S1."""
printer = await printer_factory(name="P1")
mock_client = MagicMock()
mock_client.send_gcode.return_value = True
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-5&force=true")
assert response.status_code == 200
sent_gcode = mock_client.send_gcode.call_args[0][0]
lines = sent_gcode.splitlines()
assert lines[0] == "M211 S0"
assert lines[-1] == "M211 S1"
assert "G1 Z-5.00" in sent_gcode
class TestHomeAxesAPI:
@pytest.mark.asyncio
async def test_home_axes_not_found(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/printers/99999/home-axes?axes=z")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_home_axes_invalid(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="P1")
response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=bogus")
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("axes", ["z", "xy", "all"])
async def test_home_axes_always_runs_full_home(self, async_client: AsyncClient, printer_factory, axes):
# Regression for #1052: regardless of the axes argument, the endpoint must send a bare
# `G28` so the printer's safe auto-home sequence (toolhead park → XY home → Z home) runs.
# Sending `G28 Z` alone on H2C/H2D/H2S/X1 can crash the bed into the toolhead.
printer = await printer_factory(name="P1")
mock_client = MagicMock()
mock_client.send_gcode.return_value = True
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes={axes}")
assert response.status_code == 200
mock_client.send_gcode.assert_called_once_with("G28")
@pytest.mark.asyncio
async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory(name="D")
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = None
response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=z")
assert response.status_code == 400