Files
bambuddy/backend/tests/unit/test_bed_jog.py
T
maziggy a2c9eef87c fix(safety): invert bed-jog Z direction on A1 / A1 Mini bed-slingers (#1334)
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
  control sent the nozzle straight into the build plate. Reporter
  triggered it with the 50 mm step and crashed their nozzle.

  Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
  where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
  so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
  gap). The frontend maps "Up" to negative distance with that convention
  in mind.

  A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
  firmware uses standard cartesian Z (Z+ = toolhead up). On those models
  G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
  classification at the bed-jog code path, so every printer got the same
  X1-convention G-code.

  Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
  existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
  already-defined A1_MODELS frozenset which covers display names and
  internal codes N1 / N2S). The bed-jog route now inverts the signed
  distance before emitting G-code when the printer model is in that set,
  so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
  regardless of which physical part moves. Frontend untouched, single
  source of truth lives in the backend, keyed off the Printer.model
  column. Route Query description and docstring updated to spell out the
  new contract: distance is the gap adjustment, not the raw Z value.
2026-05-14 08:58:33 +02:00

175 lines
9.3 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
@pytest.mark.asyncio
@pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2S", "H2C", "P2S"])
async def test_bed_jog_bed_on_z_models_pass_distance_through(
self, async_client: AsyncClient, printer_factory, model
):
"""On bed-on-Z printers the UI's signed distance maps directly to the
G-code Z value — UI "Up" (negative) → bed up (G1 Z-) → less gap."""
printer = await printer_factory(name=f"Test-{model}", model=model)
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")
assert response.status_code == 200
sent_gcode = mock_client.send_gcode.call_args[0][0]
# Negative distance from the UI → negative Z in the G-code: bed moves up.
assert "G1 Z-10.00" in sent_gcode, f"{model}: expected G1 Z-10.00 in gcode, got {sent_gcode!r}"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"model",
["A1", "A1 Mini", "A1MINI", "A1-MINI", "N1", "N2S"], # display names + internal codes
)
async def test_bed_jog_a1_models_invert_z_sign(self, async_client: AsyncClient, printer_factory, model):
"""#1334 regression: on bed-slinger A1 / A1 Mini the Z axis is the
TOOLHEAD, not the bed. The frontend sends negative distance for "Up"
(decrease gap) expecting bed-on-Z semantics, but ``G1 Z-`` on A1
drives the nozzle DOWN into the bed. The backend must invert the
sign on these models so "Up" still decreases the gap by raising the
toolhead (G1 Z+) rather than crashing it."""
printer = await printer_factory(name=f"Test-{model}", model=model)
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
# UI sends -10 for "Up" → backend must emit G1 Z+10 on A1.
response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
assert response.status_code == 200
sent_gcode = mock_client.send_gcode.call_args[0][0]
assert "G1 Z10.00" in sent_gcode, f"{model}: expected G1 Z10.00 in gcode, got {sent_gcode!r}"
assert "G1 Z-10" not in sent_gcode, f"{model}: must NOT emit negative Z for a UI 'Up' click"
@pytest.mark.asyncio
async def test_bed_jog_a1_down_arrow_drops_toolhead(self, async_client: AsyncClient, printer_factory):
"""Symmetric to the regression test: UI "Down" (positive distance,
increase gap) on A1 must lower the toolhead via G1 Z-."""
printer = await printer_factory(name="A1-Mini-Test", model="A1 Mini")
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")
assert response.status_code == 200
sent_gcode = mock_client.send_gcode.call_args[0][0]
assert "G1 Z-10.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