test_connection_manager.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """
  2. Unit tests for the FluidNC HTTP connection layer.
  3. The backend drives the board over HTTP now (no serial/GRBL transport), so these
  4. tests cover the FluidNCClient command/route formatting and the connection_manager
  5. helpers that sit on top of it. HTTP is mocked — no board required.
  6. """
  7. import pytest
  8. from unittest.mock import patch, MagicMock
  9. from modules.connection.fluidnc_client import FluidNCClient
  10. def _resp(text="ok", json_data=None, status=200):
  11. r = MagicMock()
  12. r.text = text
  13. r.status_code = status
  14. r.raise_for_status = MagicMock()
  15. if json_data is not None:
  16. r.json = MagicMock(return_value=json_data)
  17. return r
  18. class TestFluidNCClient:
  19. """Command / route formatting for the board client."""
  20. def test_run_pattern_uses_sd_run(self):
  21. client = FluidNCClient("http://board")
  22. with patch("modules.connection.fluidnc_client.requests") as req:
  23. req.get.return_value = _resp()
  24. client.run_pattern("/patterns/star.thr")
  25. url, kwargs = req.get.call_args[0][0], req.get.call_args[1]
  26. assert url == "http://board/command"
  27. assert kwargs["params"] == {"plain": "$SD/Run=/patterns/star.thr"}
  28. def test_run_pattern_with_clear_uses_sand_run(self):
  29. client = FluidNCClient("http://board")
  30. with patch("modules.connection.fluidnc_client.requests") as req:
  31. req.get.return_value = _resp()
  32. client.run_pattern("/patterns/star.thr", clear="adaptive")
  33. assert req.get.call_args[1]["params"] == {
  34. "plain": "$Sand/Run=/patterns/star.thr clear=adaptive"
  35. }
  36. def test_set_feed_mm(self):
  37. client = FluidNCClient("http://board")
  38. with patch("modules.connection.fluidnc_client.requests") as req:
  39. req.get.return_value = _resp()
  40. client.set_feed(mm=850)
  41. assert req.get.call_args[0][0] == "http://board/sand_feed"
  42. assert req.get.call_args[1]["params"] == {"mm": 850}
  43. def test_goto_includes_only_given_axes(self):
  44. client = FluidNCClient("http://board")
  45. with patch("modules.connection.fluidnc_client.requests") as req:
  46. req.get.return_value = _resp()
  47. client.goto(rho=0)
  48. assert req.get.call_args[1]["params"] == {"rho": 0}
  49. def test_stop_hits_sand_stop(self):
  50. client = FluidNCClient("http://board")
  51. with patch("modules.connection.fluidnc_client.requests") as req:
  52. req.get.return_value = _resp()
  53. client.stop()
  54. assert req.get.call_args[0][0] == "http://board/sand_stop"
  55. def test_get_status_returns_json(self):
  56. client = FluidNCClient("http://board")
  57. with patch("modules.connection.fluidnc_client.requests") as req:
  58. req.get.return_value = _resp(json_data={"state": "Idle", "theta": 1.0})
  59. st = client.get_status()
  60. assert st["state"] == "Idle"
  61. def test_reachable_true_and_false(self):
  62. client = FluidNCClient("http://board")
  63. with patch("modules.connection.fluidnc_client.requests") as req:
  64. req.get.return_value = _resp(json_data={"state": "Idle"})
  65. assert client.reachable() is True
  66. assert client.is_connected() is True
  67. req.get.side_effect = Exception("timeout")
  68. assert client.reachable() is False
  69. assert client.is_connected() is False
  70. def test_upload_file_field_naming(self):
  71. """The multipart file part is named by the full SD path, plus a '<path>S' size field."""
  72. client = FluidNCClient("http://board")
  73. with patch("modules.connection.fluidnc_client.requests") as req:
  74. req.post.return_value = _resp(text="{}", json_data={})
  75. client.upload_file("/playlists/a.txt", b"hello", "/playlists")
  76. kwargs = req.post.call_args[1]
  77. assert kwargs["params"] == {"path": "/playlists"}
  78. assert kwargs["data"] == {"/playlists/a.txtS": "5"}
  79. assert "/playlists/a.txt" in kwargs["files"]
  80. def test_delete_file_action(self):
  81. client = FluidNCClient("http://board")
  82. with patch("modules.connection.fluidnc_client.requests") as req:
  83. req.get.return_value = _resp(text="{}", json_data={"status": "ok"})
  84. client.delete_file("patterns", "old.thr")
  85. assert req.get.call_args[1]["params"]["action"] == "delete"
  86. assert req.get.call_args[1]["params"]["filename"] == "old.thr"
  87. def test_get_retries_on_503_then_succeeds(self):
  88. """A transient 503 (firmware low-memory shedding) is retried, not raised."""
  89. client = FluidNCClient("http://board")
  90. with patch("modules.connection.fluidnc_client.requests") as req, \
  91. patch("modules.connection.fluidnc_client.time.sleep"):
  92. req.get.side_effect = [
  93. _resp(status=503),
  94. _resp(json_data={"state": "Idle"}, status=200),
  95. ]
  96. result = client.get_status()
  97. assert result == {"state": "Idle"}
  98. assert req.get.call_count == 2
  99. def test_get_gives_up_after_max_503_retries(self):
  100. """A persistent 503 exhausts retries and surfaces via raise_for_status."""
  101. client = FluidNCClient("http://board")
  102. with patch("modules.connection.fluidnc_client.requests") as req, \
  103. patch("modules.connection.fluidnc_client.time.sleep"):
  104. req.get.return_value = _resp(status=503)
  105. client.get_status()
  106. # 1 initial + 2 retries = 3 attempts; the final 503 is returned to _get,
  107. # whose raise_for_status() (mocked here) would raise against a real board.
  108. assert req.get.call_count == 3
  109. class TestConnectionManagerHelpers:
  110. """Helpers in connection_manager that sit on top of the client."""
  111. def test_normalize_board_url(self):
  112. from modules.connection import connection_manager as cm
  113. assert cm._normalize_board_url("192.168.1.5") == "http://192.168.1.5"
  114. assert cm._normalize_board_url("http://x/") == "http://x"
  115. assert cm._normalize_board_url("") == ""
  116. def test_list_board_urls_returns_board_url(self, mock_state):
  117. from modules.connection import connection_manager as cm
  118. mock_state.board_url = "http://192.168.1.9"
  119. with patch("modules.connection.connection_manager.state", mock_state):
  120. ports = cm.list_board_urls()
  121. assert ports == ["http://192.168.1.9"]
  122. def test_apply_status_maps_fields(self, mock_state):
  123. from modules.connection import connection_manager as cm
  124. with patch("modules.connection.connection_manager.state", mock_state):
  125. cm.apply_status({"theta": 1.23, "rho": 0.5, "feed": 900})
  126. assert mock_state.current_theta == 1.23
  127. assert mock_state.current_rho == 0.5
  128. assert mock_state.speed == 900
  129. def test_apply_status_mirrors_health_telemetry(self, mock_state):
  130. from modules.connection import connection_manager as cm
  131. st = {
  132. "theta": 0.1, "rho": 0.2, "feed": 500,
  133. "heap": 145000, "heap_min": 98000, "heap_largest": 60000,
  134. "last_reset": "panic", "sd_ok": False, "uptime": 86400,
  135. }
  136. with patch("modules.connection.connection_manager.state", mock_state):
  137. cm.apply_status(st)
  138. assert mock_state.board_heap == 145000
  139. assert mock_state.board_heap_min == 98000
  140. assert mock_state.board_heap_largest == 60000
  141. assert mock_state.board_last_reset == "panic"
  142. assert mock_state.board_sd_ok is False
  143. assert mock_state.board_uptime == 86400
  144. def test_is_machine_idle_true(self, mock_state):
  145. from modules.connection import connection_manager as cm
  146. mock_state.conn.get_status.return_value = {"state": "Idle"}
  147. with patch("modules.connection.connection_manager.state", mock_state):
  148. assert cm.is_machine_idle() is True
  149. def test_is_machine_idle_running(self, mock_state):
  150. from modules.connection import connection_manager as cm
  151. mock_state.conn.get_status.return_value = {"state": "Run"}
  152. with patch("modules.connection.connection_manager.state", mock_state):
  153. assert cm.is_machine_idle() is False
  154. def test_is_machine_idle_no_connection(self, mock_state):
  155. from modules.connection import connection_manager as cm
  156. mock_state.conn = None
  157. with patch("modules.connection.connection_manager.state", mock_state):
  158. assert cm.is_machine_idle() is False