test_bed_jog.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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_success_without_force(self, async_client: AsyncClient, printer_factory):
  44. """When force=false the M211 guard lines must not be emitted."""
  45. printer = await printer_factory(name="P1")
  46. mock_client = MagicMock()
  47. mock_client.send_gcode.return_value = True
  48. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  49. mock_pm.get_client.return_value = mock_client
  50. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10&force=false")
  51. assert response.status_code == 200
  52. sent_gcode = mock_client.send_gcode.call_args[0][0]
  53. assert "G91" in sent_gcode
  54. assert "G1 Z10.00" in sent_gcode
  55. assert "G90" in sent_gcode
  56. assert "M211" not in sent_gcode
  57. @pytest.mark.asyncio
  58. async def test_bed_jog_success_with_force(self, async_client: AsyncClient, printer_factory):
  59. """force=true must wrap the move in M211 S0 / M211 S1."""
  60. printer = await printer_factory(name="P1")
  61. mock_client = MagicMock()
  62. mock_client.send_gcode.return_value = True
  63. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  64. mock_pm.get_client.return_value = mock_client
  65. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-5&force=true")
  66. assert response.status_code == 200
  67. sent_gcode = mock_client.send_gcode.call_args[0][0]
  68. lines = sent_gcode.splitlines()
  69. assert lines[0] == "M211 S0"
  70. assert lines[-1] == "M211 S1"
  71. assert "G1 Z-5.00" in sent_gcode
  72. @pytest.mark.asyncio
  73. @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2S", "H2C", "P2S"])
  74. async def test_bed_jog_bed_on_z_models_pass_distance_through(
  75. self, async_client: AsyncClient, printer_factory, model
  76. ):
  77. """On bed-on-Z printers the UI's signed distance maps directly to the
  78. G-code Z value — UI "Up" (negative) → bed up (G1 Z-) → less gap."""
  79. printer = await printer_factory(name=f"Test-{model}", model=model)
  80. mock_client = MagicMock()
  81. mock_client.send_gcode.return_value = True
  82. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  83. mock_pm.get_client.return_value = mock_client
  84. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
  85. assert response.status_code == 200
  86. sent_gcode = mock_client.send_gcode.call_args[0][0]
  87. # Negative distance from the UI → negative Z in the G-code: bed moves up.
  88. assert "G1 Z-10.00" in sent_gcode, f"{model}: expected G1 Z-10.00 in gcode, got {sent_gcode!r}"
  89. @pytest.mark.asyncio
  90. @pytest.mark.parametrize(
  91. "model",
  92. ["A1", "A1 Mini", "A1MINI", "A1-MINI", "N1", "N2S"], # display names + internal codes
  93. )
  94. async def test_bed_jog_a1_models_invert_z_sign(self, async_client: AsyncClient, printer_factory, model):
  95. """#1334 regression: on bed-slinger A1 / A1 Mini the Z axis is the
  96. TOOLHEAD, not the bed. The frontend sends negative distance for "Up"
  97. (decrease gap) expecting bed-on-Z semantics, but ``G1 Z-`` on A1
  98. drives the nozzle DOWN into the bed. The backend must invert the
  99. sign on these models so "Up" still decreases the gap by raising the
  100. toolhead (G1 Z+) rather than crashing it."""
  101. printer = await printer_factory(name=f"Test-{model}", model=model)
  102. mock_client = MagicMock()
  103. mock_client.send_gcode.return_value = True
  104. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  105. mock_pm.get_client.return_value = mock_client
  106. # UI sends -10 for "Up" → backend must emit G1 Z+10 on A1.
  107. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=-10")
  108. assert response.status_code == 200
  109. sent_gcode = mock_client.send_gcode.call_args[0][0]
  110. assert "G1 Z10.00" in sent_gcode, f"{model}: expected G1 Z10.00 in gcode, got {sent_gcode!r}"
  111. assert "G1 Z-10" not in sent_gcode, f"{model}: must NOT emit negative Z for a UI 'Up' click"
  112. @pytest.mark.asyncio
  113. async def test_bed_jog_a1_down_arrow_drops_toolhead(self, async_client: AsyncClient, printer_factory):
  114. """Symmetric to the regression test: UI "Down" (positive distance,
  115. increase gap) on A1 must lower the toolhead via G1 Z-."""
  116. printer = await printer_factory(name="A1-Mini-Test", model="A1 Mini")
  117. mock_client = MagicMock()
  118. mock_client.send_gcode.return_value = True
  119. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  120. mock_pm.get_client.return_value = mock_client
  121. response = await async_client.post(f"/api/v1/printers/{printer.id}/bed-jog?distance=10")
  122. assert response.status_code == 200
  123. sent_gcode = mock_client.send_gcode.call_args[0][0]
  124. assert "G1 Z-10.00" in sent_gcode
  125. class TestHomeAxesAPI:
  126. @pytest.mark.asyncio
  127. async def test_home_axes_not_found(self, async_client: AsyncClient):
  128. response = await async_client.post("/api/v1/printers/99999/home-axes?axes=z")
  129. assert response.status_code == 404
  130. @pytest.mark.asyncio
  131. async def test_home_axes_invalid(self, async_client: AsyncClient, printer_factory):
  132. printer = await printer_factory(name="P1")
  133. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=bogus")
  134. assert response.status_code == 400
  135. @pytest.mark.asyncio
  136. @pytest.mark.parametrize("axes", ["z", "xy", "all"])
  137. async def test_home_axes_always_runs_full_home(self, async_client: AsyncClient, printer_factory, axes):
  138. # Regression for #1052: regardless of the axes argument, the endpoint must send a bare
  139. # `G28` so the printer's safe auto-home sequence (toolhead park → XY home → Z home) runs.
  140. # Sending `G28 Z` alone on H2C/H2D/H2S/X1 can crash the bed into the toolhead.
  141. printer = await printer_factory(name="P1")
  142. mock_client = MagicMock()
  143. mock_client.send_gcode.return_value = True
  144. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  145. mock_pm.get_client.return_value = mock_client
  146. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes={axes}")
  147. assert response.status_code == 200
  148. mock_client.send_gcode.assert_called_once_with("G28")
  149. @pytest.mark.asyncio
  150. async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory):
  151. printer = await printer_factory(name="D")
  152. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  153. mock_pm.get_client.return_value = None
  154. response = await async_client.post(f"/api/v1/printers/{printer.id}/home-axes?axes=z")
  155. assert response.status_code == 400