""" Unit tests for the FluidNC HTTP connection layer. The backend drives the board over HTTP now (no serial/GRBL transport), so these tests cover the FluidNCClient command/route formatting and the connection_manager helpers that sit on top of it. HTTP is mocked — no board required. """ import pytest from unittest.mock import patch, MagicMock from modules.connection.fluidnc_client import FluidNCClient def _resp(text="ok", json_data=None, status=200): r = MagicMock() r.text = text r.status_code = status r.raise_for_status = MagicMock() if json_data is not None: r.json = MagicMock(return_value=json_data) return r class TestFluidNCClient: """Command / route formatting for the board client.""" def test_run_pattern_uses_sd_run(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp() client.run_pattern("/patterns/star.thr") url, kwargs = req.get.call_args[0][0], req.get.call_args[1] assert url == "http://board/command" assert kwargs["params"] == {"plain": "$SD/Run=/patterns/star.thr"} def test_run_pattern_with_clear_uses_sand_run(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp() client.run_pattern("/patterns/star.thr", clear="adaptive") assert req.get.call_args[1]["params"] == { "plain": "$Sand/Run=/patterns/star.thr clear=adaptive" } def test_set_feed_mm(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp() client.set_feed(mm=850) assert req.get.call_args[0][0] == "http://board/sand_feed" assert req.get.call_args[1]["params"] == {"mm": 850} def test_goto_includes_only_given_axes(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp() client.goto(rho=0) assert req.get.call_args[1]["params"] == {"rho": 0} def test_stop_hits_sand_stop(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp() client.stop() assert req.get.call_args[0][0] == "http://board/sand_stop" def test_get_status_returns_json(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp(json_data={"state": "Idle", "theta": 1.0}) st = client.get_status() assert st["state"] == "Idle" def test_reachable_true_and_false(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp(json_data={"state": "Idle"}) assert client.reachable() is True assert client.is_connected() is True req.get.side_effect = Exception("timeout") assert client.reachable() is False assert client.is_connected() is False def test_upload_file_field_naming(self): """The multipart file part is named by the full SD path, plus a 'S' size field.""" client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.post.return_value = _resp(text="{}", json_data={}) client.upload_file("/playlists/a.txt", b"hello", "/playlists") kwargs = req.post.call_args[1] assert kwargs["params"] == {"path": "/playlists"} assert kwargs["data"] == {"/playlists/a.txtS": "5"} assert "/playlists/a.txt" in kwargs["files"] def test_delete_file_action(self): client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req: req.get.return_value = _resp(text="{}", json_data={"status": "ok"}) client.delete_file("patterns", "old.thr") assert req.get.call_args[1]["params"]["action"] == "delete" assert req.get.call_args[1]["params"]["filename"] == "old.thr" def test_get_retries_on_503_then_succeeds(self): """A transient 503 (firmware low-memory shedding) is retried, not raised.""" client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req, \ patch("modules.connection.fluidnc_client.time.sleep"): req.get.side_effect = [ _resp(status=503), _resp(json_data={"state": "Idle"}, status=200), ] result = client.get_status() assert result == {"state": "Idle"} assert req.get.call_count == 2 def test_get_gives_up_after_max_503_retries(self): """A persistent 503 exhausts retries and surfaces via raise_for_status.""" client = FluidNCClient("http://board") with patch("modules.connection.fluidnc_client.requests") as req, \ patch("modules.connection.fluidnc_client.time.sleep"): req.get.return_value = _resp(status=503) client.get_status() # 1 initial + 2 retries = 3 attempts; the final 503 is returned to _get, # whose raise_for_status() (mocked here) would raise against a real board. assert req.get.call_count == 3 class TestConnectionManagerHelpers: """Helpers in connection_manager that sit on top of the client.""" def test_normalize_board_url(self): from modules.connection import connection_manager as cm assert cm._normalize_board_url("192.168.1.5") == "http://192.168.1.5" assert cm._normalize_board_url("http://x/") == "http://x" assert cm._normalize_board_url("") == "" def test_list_board_urls_returns_board_url(self, mock_state): from modules.connection import connection_manager as cm mock_state.board_url = "http://192.168.1.9" with patch("modules.connection.connection_manager.state", mock_state): ports = cm.list_board_urls() assert ports == ["http://192.168.1.9"] def test_apply_status_maps_fields(self, mock_state): from modules.connection import connection_manager as cm with patch("modules.connection.connection_manager.state", mock_state): cm.apply_status({"theta": 1.23, "rho": 0.5, "feed": 900}) assert mock_state.current_theta == 1.23 assert mock_state.current_rho == 0.5 assert mock_state.speed == 900 def test_apply_status_mirrors_health_telemetry(self, mock_state): from modules.connection import connection_manager as cm st = { "theta": 0.1, "rho": 0.2, "feed": 500, "heap": 145000, "heap_min": 98000, "heap_largest": 60000, "last_reset": "panic", "sd_ok": False, "uptime": 86400, } with patch("modules.connection.connection_manager.state", mock_state): cm.apply_status(st) assert mock_state.board_heap == 145000 assert mock_state.board_heap_min == 98000 assert mock_state.board_heap_largest == 60000 assert mock_state.board_last_reset == "panic" assert mock_state.board_sd_ok is False assert mock_state.board_uptime == 86400 def test_is_machine_idle_true(self, mock_state): from modules.connection import connection_manager as cm mock_state.conn.get_status.return_value = {"state": "Idle"} with patch("modules.connection.connection_manager.state", mock_state): assert cm.is_machine_idle() is True def test_is_machine_idle_running(self, mock_state): from modules.connection import connection_manager as cm mock_state.conn.get_status.return_value = {"state": "Run"} with patch("modules.connection.connection_manager.state", mock_state): assert cm.is_machine_idle() is False def test_is_machine_idle_no_connection(self, mock_state): from modules.connection import connection_manager as cm mock_state.conn = None with patch("modules.connection.connection_manager.state", mock_state): assert cm.is_machine_idle() is False