test_bed_jog.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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>&force=<bool>
  4. POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>
  5. """
  6. from unittest.mock import MagicMock, patch
  7. import pytest
  8. from httpx import AsyncClient
  9. class TestBedJogAPI:
  10. @pytest.mark.asyncio
  11. async def test_bed_jog_not_found(self, async_client: AsyncClient):
  12. response = await async_client.post("/api/v1/printers/99999/bed-jog?distance=10")
  13. assert response.status_code == 404
  14. @pytest.mark.asyncio
  15. async def test_bed_jog_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
  16. printer = await printer_factory(name="P1")
  17. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=0")
  18. assert response.status_code == 400
  19. assert "distance" in response.json()["detail"].lower()
  20. @pytest.mark.asyncio
  21. async def test_bed_jog_too_large_rejected(self, async_client: AsyncClient, printer_factory):
  22. printer = await printer_factory(name="P1")
  23. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=500")
  24. assert response.status_code == 400
  25. @pytest.mark.asyncio
  26. async def test_bed_jog_not_connected(self, async_client: AsyncClient, printer_factory):
  27. printer = await printer_factory(name="Disconnected")
  28. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  29. mock_pm.get_client.return_value = None
  30. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  31. assert response.status_code == 400
  32. assert "not connected" in response.json()["detail"].lower()
  33. @pytest.mark.asyncio
  34. async def test_bed_jog_send_failure(self, async_client: AsyncClient, printer_factory):
  35. printer = await printer_factory(name="P1")
  36. mock_client = MagicMock()
  37. mock_client.send_gcode.return_value = False
  38. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  39. mock_pm.get_client.return_value = mock_client
  40. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  41. assert response.status_code == 500
  42. @pytest.mark.asyncio
  43. async def test_bed_jog_emits_bare_move_and_never_touches_m211(self, async_client: AsyncClient, printer_factory):
  44. """A jog must be a bare relative move — no M211 at all — exactly what the
  45. printer's touchscreen sends, which the firmware clamps at the travel
  46. limit. Touching M211 is what broke it (#2579)."""
  47. printer = await printer_factory(name="P1")
  48. mock_client = MagicMock()
  49. mock_client.send_gcode.return_value = True
  50. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  51. mock_pm.get_client.return_value = mock_client
  52. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  53. assert response.status_code == 200
  54. sent_gcode = mock_client.send_gcode.call_args[0][0]
  55. assert "M211" not in sent_gcode, f"must not touch M211, got: {sent_gcode!r}"
  56. assert sent_gcode.splitlines() == ["G91", "G1 Z10.00 F600", "G90"]
  57. @pytest.mark.asyncio
  58. async def test_bed_jog_never_touches_m211_even_with_stray_force(self, async_client: AsyncClient, printer_factory):
  59. """#2579 core regression: the endpoint must NEVER emit any M211. A stray
  60. ?force=true from an old client is ignored (FastAPI drops the unknown
  61. param) and the move stays a bare relative move — no M211 S0 (the disable
  62. that drove the nozzle into the bed) and no M211 S1 either.
  63. """
  64. printer = await printer_factory(name="H2C", model="H2C")
  65. mock_client = MagicMock()
  66. mock_client.send_gcode.return_value = True
  67. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  68. mock_pm.get_client.return_value = mock_client
  69. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=50&force=true")
  70. assert response.status_code == 200
  71. sent_gcode = mock_client.send_gcode.call_args[0][0]
  72. assert "M211" not in sent_gcode, f"must never touch M211, got: {sent_gcode!r}"
  73. assert "G1 Z50.00" in sent_gcode
  74. @pytest.mark.asyncio
  75. @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2S", "H2C", "P2S"])
  76. async def test_bed_jog_bed_on_z_models_pass_distance_through(
  77. self, async_client: AsyncClient, printer_factory, model
  78. ):
  79. """On bed-on-Z printers the UI's signed distance maps directly to the
  80. G-code Z value — UI "Up" (negative) → bed up (G1 Z-) → less gap."""
  81. printer = await printer_factory(name=f"Test-{model}", model=model)
  82. mock_client = MagicMock()
  83. mock_client.send_gcode.return_value = True
  84. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  85. mock_pm.get_client.return_value = mock_client
  86. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
  87. assert response.status_code == 200
  88. sent_gcode = mock_client.send_gcode.call_args[0][0]
  89. # Negative distance from the UI → negative Z in the G-code: bed moves up.
  90. assert "G1 Z-10.00" in sent_gcode, f"{model}: expected G1 Z-10.00 in gcode, got {sent_gcode!r}"
  91. @pytest.mark.asyncio
  92. @pytest.mark.parametrize(
  93. "model",
  94. ["A1", "A1 Mini", "A1MINI", "A1-MINI", "N1", "N2S"], # display names + internal codes
  95. )
  96. async def test_bed_jog_a1_models_invert_z_sign(self, async_client: AsyncClient, printer_factory, model):
  97. """#1334 regression: on bed-slinger A1 / A1 Mini the Z axis is the
  98. TOOLHEAD, not the bed. The frontend sends negative distance for "Up"
  99. (decrease gap) expecting bed-on-Z semantics, but ``G1 Z-`` on A1
  100. drives the nozzle DOWN into the bed. The backend must invert the
  101. sign on these models so "Up" still decreases the gap by raising the
  102. toolhead (G1 Z+) rather than crashing it."""
  103. printer = await printer_factory(name=f"Test-{model}", model=model)
  104. mock_client = MagicMock()
  105. mock_client.send_gcode.return_value = True
  106. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  107. mock_pm.get_client.return_value = mock_client
  108. # UI sends -10 for "Up" → backend must emit G1 Z+10 on A1.
  109. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
  110. assert response.status_code == 200
  111. sent_gcode = mock_client.send_gcode.call_args[0][0]
  112. assert "G1 Z10.00" in sent_gcode, f"{model}: expected G1 Z10.00 in gcode, got {sent_gcode!r}"
  113. assert "G1 Z-10" not in sent_gcode, f"{model}: must NOT emit negative Z for a UI 'Up' click"
  114. @pytest.mark.asyncio
  115. async def test_bed_jog_a1_down_arrow_drops_toolhead(self, async_client: AsyncClient, printer_factory):
  116. """Symmetric to the regression test: UI "Down" (positive distance,
  117. increase gap) on A1 must lower the toolhead via G1 Z-."""
  118. printer = await printer_factory(name="A1-Mini-Test", model="A1 Mini")
  119. mock_client = MagicMock()
  120. mock_client.send_gcode.return_value = True
  121. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  122. mock_pm.get_client.return_value = mock_client
  123. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  124. assert response.status_code == 200
  125. sent_gcode = mock_client.send_gcode.call_args[0][0]
  126. assert "G1 Z-10.00" in sent_gcode
  127. class TestHomeAxesAPI:
  128. @pytest.mark.asyncio
  129. async def test_home_axes_not_found(self, async_client: AsyncClient):
  130. response = await async_client.post("/api/v1/printers/99999/home-axes?axes=z")
  131. assert response.status_code == 404
  132. @pytest.mark.asyncio
  133. async def test_home_axes_invalid(self, async_client: AsyncClient, printer_factory):
  134. printer = await printer_factory(name="P1")
  135. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=bogus")
  136. assert response.status_code == 400
  137. @pytest.mark.asyncio
  138. @pytest.mark.parametrize("axes", ["z", "xy", "all"])
  139. async def test_home_axes_always_runs_full_home(self, async_client: AsyncClient, printer_factory, axes):
  140. # Regression for #1052: regardless of the axes argument, the endpoint must send a bare
  141. # `G28` so the printer's safe auto-home sequence (toolhead park → XY home → Z home) runs.
  142. # Sending `G28 Z` alone on H2C/H2D/H2S/X1 can crash the bed into the toolhead.
  143. printer = await printer_factory(name="P1")
  144. mock_client = MagicMock()
  145. mock_client.send_gcode.return_value = True
  146. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  147. mock_pm.get_client.return_value = mock_client
  148. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes={axes}")
  149. assert response.status_code == 200
  150. mock_client.send_gcode.assert_called_once_with("G28")
  151. @pytest.mark.asyncio
  152. async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory):
  153. printer = await printer_factory(name="D")
  154. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  155. mock_pm.get_client.return_value = None
  156. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=z")
  157. assert response.status_code == 400