test_bed_jog.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """Unit tests for the bed-jog and home-axes endpoints (#791).
  2. Tests:
  3. POST /api/v1/printers/{printer_id}/bed-jog?distance=<mm>
  4. POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>
  5. ``distance`` is a signed nozzle-bed gap and ``axes`` is accepted but always
  6. homes everything — both endpoints once took a second parameter that made them
  7. do something more clever, and both parameters are gone for the same reason
  8. (#2579, #1052): on a machine with a nozzle and a plate, the clever version is
  9. the one that ends with them touching.
  10. """
  11. from unittest.mock import MagicMock, patch
  12. import pytest
  13. from httpx import AsyncClient
  14. class TestBedJogAPI:
  15. @pytest.mark.asyncio
  16. async def test_bed_jog_not_found(self, async_client: AsyncClient):
  17. response = await async_client.post("/api/v1/printers/99999/bed-jog?distance=10")
  18. assert response.status_code == 404
  19. @pytest.mark.asyncio
  20. async def test_bed_jog_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
  21. printer = await printer_factory(name="P1")
  22. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=0")
  23. assert response.status_code == 400
  24. assert "distance" in response.json()["detail"].lower()
  25. @pytest.mark.asyncio
  26. async def test_bed_jog_too_large_rejected(self, async_client: AsyncClient, printer_factory):
  27. printer = await printer_factory(name="P1")
  28. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=500")
  29. assert response.status_code == 400
  30. @pytest.mark.asyncio
  31. async def test_bed_jog_not_connected(self, async_client: AsyncClient, printer_factory):
  32. printer = await printer_factory(name="Disconnected")
  33. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  34. mock_pm.get_client.return_value = None
  35. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  36. assert response.status_code == 400
  37. assert "not connected" in response.json()["detail"].lower()
  38. @pytest.mark.asyncio
  39. async def test_bed_jog_send_failure(self, async_client: AsyncClient, printer_factory):
  40. printer = await printer_factory(name="P1")
  41. mock_client = MagicMock()
  42. mock_client.send_gcode.return_value = False
  43. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  44. mock_pm.get_client.return_value = mock_client
  45. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  46. assert response.status_code == 500
  47. @pytest.mark.asyncio
  48. async def test_bed_jog_emits_bare_move_and_never_touches_m211(self, async_client: AsyncClient, printer_factory):
  49. """A jog must be a bare relative move — no M211 at all (#2579).
  50. Not because a bare move is clamped: the firmware ignores soft endstops
  51. on MQTT G-code whatever we send. But ``M211 S0`` disabled them
  52. *globally*, so Bambuddy was also taking away the protection on the
  53. printer's own touchscreen, and that part was ours to stop doing."""
  54. printer = await printer_factory(name="P1")
  55. mock_client = MagicMock()
  56. mock_client.send_gcode.return_value = True
  57. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  58. mock_pm.get_client.return_value = mock_client
  59. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  60. assert response.status_code == 200
  61. sent_gcode = mock_client.send_gcode.call_args[0][0]
  62. assert "M211" not in sent_gcode, f"must not touch M211, got: {sent_gcode!r}"
  63. assert sent_gcode.splitlines() == ["G91", "G1 Z10.00 F600", "G90"]
  64. @pytest.mark.asyncio
  65. async def test_bed_jog_never_touches_m211_even_with_stray_force(self, async_client: AsyncClient, printer_factory):
  66. """#2579 core regression: the endpoint must NEVER emit any M211. A stray
  67. ?force=true from an old client is ignored (FastAPI drops the unknown
  68. param) and the move stays a bare relative move — no M211 S0 (the disable
  69. that drove the nozzle into the bed) and no M211 S1 either.
  70. """
  71. printer = await printer_factory(name="H2C", model="H2C")
  72. mock_client = MagicMock()
  73. mock_client.send_gcode.return_value = True
  74. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  75. mock_pm.get_client.return_value = mock_client
  76. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=50&force=true")
  77. assert response.status_code == 200
  78. sent_gcode = mock_client.send_gcode.call_args[0][0]
  79. assert "M211" not in sent_gcode, f"must never touch M211, got: {sent_gcode!r}"
  80. assert "G1 Z50.00" in sent_gcode
  81. @pytest.mark.asyncio
  82. @pytest.mark.parametrize(
  83. "model",
  84. [
  85. # bed-on-Z
  86. "X1C",
  87. "P1S",
  88. "H2D",
  89. "H2S",
  90. "H2C",
  91. "P2S",
  92. # bed-slingers — the Z axis carries the toolhead instead
  93. "A1",
  94. "A1 Mini",
  95. "A1MINI",
  96. "A1-MINI",
  97. "A2L",
  98. "N1",
  99. "N2S",
  100. "N9",
  101. ],
  102. )
  103. @pytest.mark.parametrize("distance", [-10, 10])
  104. async def test_bed_jog_sends_the_distance_unchanged_on_every_model(
  105. self, async_client: AsyncClient, printer_factory, model, distance
  106. ):
  107. """``distance`` is a nozzle-bed gap, and a gap is a gap on every printer.
  108. ``G1 Z+`` opens the nozzle-bed gap whether the bed drops away from the
  109. nozzle (X1 / P1 / H2) or the toolhead rises off the plate (A1 / A2L) —
  110. that is what the Z axis *means*, not a per-family convention. So one
  111. API call describes one physical outcome everywhere, and the route has
  112. no model branch to get wrong.
  113. It had one once. #1334 was a bed-slinger owner clicking an arrow
  114. labelled "move the plate up" and watching the nozzle dive, and the fix
  115. inverted the G-code sign on A1 models. That made a documented
  116. model-independent parameter mean the opposite thing on those printers:
  117. @AQU4R1U5 asked for 5 mm of clearance through the API and got 5 mm less.
  118. """
  119. printer = await printer_factory(name=f"Test-{model}", model=model)
  120. mock_client = MagicMock()
  121. mock_client.send_gcode.return_value = True
  122. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  123. mock_pm.get_client.return_value = mock_client
  124. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance={distance}")
  125. assert response.status_code == 200
  126. sent_gcode = mock_client.send_gcode.call_args[0][0]
  127. assert f"G1 Z{distance:.2f} F600" in sent_gcode, f"{model}: got {sent_gcode!r}"
  128. @pytest.mark.asyncio
  129. @pytest.mark.parametrize("model", ["A1", "A1 Mini", "A2L", "N1", "N2S", "N9"])
  130. async def test_bed_jog_positive_is_the_safe_direction_on_bed_slingers(
  131. self, async_client: AsyncClient, printer_factory, model
  132. ):
  133. """The one that bit @AQU4R1U5: asking for clearance must never close the gap.
  134. Spelled out separately from the pass-through test above because this is
  135. the property that matters to anyone driving the API from a script — the
  136. sign of ``distance`` is the only thing standing between "lift the nozzle
  137. off my print" and a nozzle in the plate, and it must not depend on which
  138. printer is on the other end.
  139. """
  140. printer = await printer_factory(name=f"Test-{model}", model=model)
  141. mock_client = MagicMock()
  142. mock_client.send_gcode.return_value = True
  143. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  144. mock_pm.get_client.return_value = mock_client
  145. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=5")
  146. assert response.status_code == 200
  147. sent_gcode = mock_client.send_gcode.call_args[0][0]
  148. assert "G1 Z-" not in sent_gcode, f"{model}: clearance request closed the gap — {sent_gcode!r}"
  149. assert "G1 Z5.00" in sent_gcode
  150. class TestHomeAxesAPI:
  151. @pytest.mark.asyncio
  152. async def test_home_axes_not_found(self, async_client: AsyncClient):
  153. response = await async_client.post("/api/v1/printers/99999/home-axes?axes=z")
  154. assert response.status_code == 404
  155. @pytest.mark.asyncio
  156. async def test_home_axes_invalid(self, async_client: AsyncClient, printer_factory):
  157. printer = await printer_factory(name="P1")
  158. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=bogus")
  159. assert response.status_code == 400
  160. @pytest.mark.asyncio
  161. @pytest.mark.parametrize("axes", ["z", "xy", "all"])
  162. async def test_home_axes_always_runs_full_home(self, async_client: AsyncClient, printer_factory, axes):
  163. # Regression for #1052: regardless of the axes argument, the endpoint must send a bare
  164. # `G28` so the printer's safe auto-home sequence (toolhead park → XY home → Z home) runs.
  165. # Sending `G28 Z` alone on H2C/H2D/H2S/X1 can crash the bed into the toolhead.
  166. printer = await printer_factory(name="P1")
  167. mock_client = MagicMock()
  168. mock_client.send_gcode.return_value = True
  169. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  170. mock_pm.get_client.return_value = mock_client
  171. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes={axes}")
  172. assert response.status_code == 200
  173. mock_client.send_gcode.assert_called_once_with("G28")
  174. @pytest.mark.asyncio
  175. async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory):
  176. printer = await printer_factory(name="D")
  177. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  178. mock_pm.get_client.return_value = None
  179. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=z")
  180. assert response.status_code == 400