diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index 0b47f367e..8b50b3796 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -1,6 +1,7 @@ # pylint: skip-file from unittest.mock import MagicMock +from bec_lib.config_helper import ConfigHelper from bec_lib.device import Device as BECDevice from bec_lib.device import Positioner as BECPositioner from bec_lib.device import ReadoutPriority @@ -219,7 +220,9 @@ def __init__(self, name, enabled=True): class DMMock: - def __init__(self): + def __init__(self, *args, **kwargs): + self._service = args[0] + self.config_helper = ConfigHelper(self._service.connector, self._service._service_name) self.devices = DeviceContainer() self.enabled_devices = [device for device in self.devices if device.enabled] @@ -273,6 +276,10 @@ def _get_redis_device_config(self) -> list[dict]: configs.append(device._config) return configs + def initialize(*_): ... + + def shutdown(self): ... + DEVICES = [ FakePositioner("samx", limits=[-10, 10], read_value=2.0), diff --git a/bec_widgets/utils/bec_dispatcher.py b/bec_widgets/utils/bec_dispatcher.py index 8c5690c19..08a26f150 100644 --- a/bec_widgets/utils/bec_dispatcher.py +++ b/bec_widgets/utils/bec_dispatcher.py @@ -123,17 +123,16 @@ def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_i self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = ( collections.defaultdict() ) - self.client = client - if self.client is None: - if config is not None: - if not isinstance(config, ServiceConfig): - # config is supposed to be a path - config = ServiceConfig(config) + if client is None: + if config is not None and not isinstance(config, ServiceConfig): + # config is supposed to be a path + config = ServiceConfig(config) self.client = BECClient( config=config, connector_cls=QtRedisConnector, name="BECWidgets" ) else: + self.client = client if self.client.started: # have to reinitialize client to use proper connector logger.info("Shutting down BECClient to switch to QtRedisConnector") diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 68ff10353..d71952283 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -63,7 +63,7 @@ def __init__(self, parent=None): layout.setSpacing(0) # Create tree view - self.tree = QTreeView() + self.tree = QTreeView(parent=self) self.tree.setHeaderHidden(True) self.tree.setRootIsDecorated(True) @@ -71,12 +71,12 @@ def __init__(self, parent=None): self.tree.setMouseTracking(True) # Create file system model - self.model = QFileSystemModel() + self.model = QFileSystemModel(parent=self) self.model.setNameFilters(["*.py"]) self.model.setNameFilterDisables(False) # Create proxy model to filter out underscore directories - self.proxy_model = QSortFilterProxyModel() + self.proxy_model = QSortFilterProxyModel(parent=self) self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*")) self.proxy_model.setSourceModel(self.model) self.tree.setModel(self.proxy_model) diff --git a/tests/unit_tests/client_mocks.py b/tests/unit_tests/client_mocks.py index 613119e02..80c056dcb 100644 --- a/tests/unit_tests/client_mocks.py +++ b/tests/unit_tests/client_mocks.py @@ -1,47 +1,18 @@ # pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring from math import inf -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import fakeredis import pytest from bec_lib.bec_service import messages from bec_lib.endpoints import MessageEndpoints -from bec_lib.redis_connector import RedisConnector from bec_lib.scan_history import ScanHistory -from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner - - -def fake_redis_server(host, port, **kwargs): - redis = fakeredis.FakeRedis() - return redis +from bec_widgets.tests.utils import FakePositioner, Positioner @pytest.fixture(scope="function") def mocked_client(bec_dispatcher): - connector = RedisConnector("localhost:1", redis_cls=fake_redis_server) - # Create a MagicMock object - client = MagicMock() # TODO change to real BECClient - - # Shutdown the original client - bec_dispatcher.client.shutdown() - # Mock the connector attribute - bec_dispatcher.client = client - - # Mock the device_manager.devices attribute - client.connector = connector - client.device_manager = DMMock() - client.device_manager.add_devices(DEVICES) - - def mock_mv(*args, relative=False): - # Extracting motor and value pairs - for i in range(0, len(args), 2): - motor = args[i] - value = args[i + 1] - motor.move(value, relative=relative) - return MagicMock(wait=MagicMock()) - - client.scans = MagicMock(mv=mock_mv) # Ensure isinstance check for Positioner passes original_isinstance = isinstance @@ -52,8 +23,8 @@ def isinstance_mock(obj, class_info): return original_isinstance(obj, class_info) with patch("builtins.isinstance", new=isinstance_mock): - yield client - connector.shutdown() # TODO change to real BECClient + yield bec_dispatcher.client + bec_dispatcher.client.connector.shutdown() ################################################## @@ -190,17 +161,16 @@ def mocked_client_with_dap(mocked_client, dap_plugin_message): name="LmfitService1D", status=1, info={} ), } - client = mocked_client - client.service_status = dap_services - client.connector.set( + type(mocked_client).service_status = PropertyMock(return_value=dap_services) + mocked_client.connector.set( topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message ) # Patch the client's DAP attribute so that the available models include "GaussianModel" patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}} - client.dap._available_dap_plugins = patched_models + mocked_client.dap._available_dap_plugins = patched_models - yield client + yield mocked_client class DummyData: @@ -233,7 +203,6 @@ def create_dummy_scan_item(): "readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]}, } } - dummy_scan.status_message = MagicMock() dummy_scan.status_message.info = { "readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]}, "scan_report_devices": ["samx"], diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 6f81a4cf8..2e9b31c95 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -1,19 +1,28 @@ import json import time +from unittest import mock +from unittest.mock import patch +import fakeredis import h5py import numpy as np import pytest -from bec_lib import messages +from bec_lib import messages, service_config +from bec_lib.bec_service import messages +from bec_lib.client import BECClient from bec_lib.messages import _StoredDataInfo from bec_qthemes import apply_theme +from bec_qthemes._theme import Theme +from ophyd._pyepics_shim import _dispatcher from pytestqt.exceptions import TimeoutError as QtBotTimeoutError from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtWidgets import QApplication, QMessageBox from bec_widgets.cli.rpc.rpc_register import RPCRegister +from bec_widgets.tests.utils import DEVICES, DMMock from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module from bec_widgets.utils import error_popups +from bec_widgets.utils.bec_dispatcher import QtRedisConnector # Patch to set default RAISE_ERROR_DEFAULT to True for tests # This means that by default, error popups will raise exceptions during tests @@ -38,15 +47,20 @@ def process_all_deferred_deletes(qapp): def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument qapp = QApplication.instance() process_all_deferred_deletes(qapp) - apply_theme("light") - qapp.processEvents() + + if ( + not hasattr(qapp, "theme") + or not isinstance(qapp.theme, Theme) + or qapp.theme.theme != "light" + ): + apply_theme("light") + qapp.processEvents() yield # if the test failed, we don't want to check for open widgets as # it simply pollutes the output # stop pyepics dispatcher for leaking tests - from ophyd._pyepics_shim import _dispatcher _dispatcher.stop() if request.node.stash._storage.get("failed"): @@ -71,9 +85,37 @@ def rpc_register(): RPCRegister.reset_singleton() +_REDIS_CONN: QtRedisConnector | None = None + + +def global_mock_qt_redis_connector(*_, **__): + global _REDIS_CONN + if _REDIS_CONN is None: + _REDIS_CONN = QtRedisConnector(bootstrap="localhost:1", redis_cls=fakeredis.FakeRedis) + return _REDIS_CONN + + +def mock_client(*_, **__): + with ( + patch("bec_lib.client.DeviceManagerBase", DMMock), + patch("bec_lib.client.DAPPlugins"), + patch("bec_lib.client.Scans"), + patch("bec_lib.client.ScanManager"), + patch("bec_lib.bec_service.BECAccess"), + ): + client = BECClient( + config=service_config.ServiceConfig(config={"redis": {"host": "localhost", "port": 1}}), + connector_cls=global_mock_qt_redis_connector, + ) + client.start() + client.device_manager.add_devices(DEVICES) + return client + + @pytest.fixture(autouse=True) def bec_dispatcher(threads_check): # pylint: disable=unused-argument - bec_dispatcher = bec_dispatcher_module.BECDispatcher() + with mock.patch.object(bec_dispatcher_module, "BECClient", mock_client): + bec_dispatcher = bec_dispatcher_module.BECDispatcher() yield bec_dispatcher bec_dispatcher.disconnect_all() # clean BEC client diff --git a/tests/unit_tests/test_abort_button.py b/tests/unit_tests/test_abort_button.py index d744bd048..44eb76984 100644 --- a/tests/unit_tests/test_abort_button.py +++ b/tests/unit_tests/test_abort_button.py @@ -1,5 +1,7 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +from unittest.mock import MagicMock + import pytest from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton @@ -10,6 +12,7 @@ @pytest.fixture def abort_button(qtbot, mocked_client): widget = AbortButton(client=mocked_client) + widget.queue = MagicMock() qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget diff --git a/tests/unit_tests/test_bec_dispatcher.py b/tests/unit_tests/test_bec_dispatcher.py index 69b28a50b..12b57c673 100644 --- a/tests/unit_tests/test_bec_dispatcher.py +++ b/tests/unit_tests/test_bec_dispatcher.py @@ -4,10 +4,45 @@ from unittest import mock import pytest +from bec_lib import service_config from bec_lib.messages import ScanMessage from bec_lib.serialization import MsgpackSerialization -from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback +from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback + + +def test_init_handles_client_and_config_arg(): + # Client passed + self_mock = mock.MagicMock(_initialized=False) + with mock.patch.object(BECDispatcher, "start_cli_server"): + BECDispatcher.__init__(self_mock, client=mock.MagicMock(name="test_client")) + assert "test_client" in repr(self_mock.client) + + # No client, service config object + self_mock.reset_mock() + self_mock._initialized = False + with ( + mock.patch.object(BECDispatcher, "start_cli_server"), + mock.patch("bec_widgets.utils.bec_dispatcher.BECClient") as client_cls, + ): + config = service_config.ServiceConfig() + BECDispatcher.__init__(self_mock, client=None, config=config) + client_cls.assert_called_with( + config=config, connector_cls=QtRedisConnector, name="BECWidgets" + ) + + # No client, service config string + self_mock.reset_mock() + self_mock._initialized = False + with ( + mock.patch.object(BECDispatcher, "start_cli_server"), + mock.patch("bec_widgets.utils.bec_dispatcher.BECClient"), + mock.patch("bec_widgets.utils.bec_dispatcher.ServiceConfig") as svc_cfg, + mock.patch("bec_widgets.utils.bec_dispatcher.isinstance", return_value=False), + ): + config = service_config.ServiceConfig() + BECDispatcher.__init__(self_mock, client=None, config="test_str") + svc_cfg.assert_called_with("test_str") @pytest.fixture diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 7c36594ee..f996d96ae 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -160,7 +160,7 @@ def test_signal_display(mocked_client, qtbot): def test_signal_display_no_device(mocked_client, qtbot): device_mock = mock.MagicMock() - mocked_client.client.device_manager.devices = {"test_device_1": device_mock} + mocked_client.device_manager.devices = {"test_device_1": device_mock} signal_display = SignalDisplay(client=mocked_client, device="test_device_2") qtbot.addWidget(signal_display) assert ( diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index 78329344f..fbeb45514 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -146,12 +146,12 @@ def test_signal_lineedit(device_signal_line_edit): def test_device_signal_input_base_cleanup(qtbot, mocked_client): + with mock.patch.object(mocked_client.callbacks, "remove"): + widget = DeviceInputWidget(client=mocked_client) + widget.close() + widget.deleteLater() - widget = DeviceInputWidget(client=mocked_client) - widget.close() - widget.deleteLater() - - mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register) + mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register) def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox): diff --git a/tests/unit_tests/test_scatter_waveform.py b/tests/unit_tests/test_scatter_waveform.py index f0ba5620a..3a8abcf3b 100644 --- a/tests/unit_tests/test_scatter_waveform.py +++ b/tests/unit_tests/test_scatter_waveform.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import numpy as np @@ -53,14 +53,16 @@ def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeyp swf = create_widget(qtbot, ScatterWaveform, client=mocked_client) dummy_scan = create_dummy_scan_item() + mocked_client.history = MagicMock() + # .get_by_scan_id() typically returns historical data, but we abuse it here + # to return mock live data mocked_client.history.get_by_scan_id.return_value = dummy_scan mocked_client.history.__getitem__.return_value = dummy_scan swf.plot("samx", "samy", "bpm4i", label="test_curve") swf.update_with_scan_history(scan_id="dummy") - qtbot.wait(500) - - assert swf.scan_item == dummy_scan + qtbot.waitUntil(lambda: swf.scan_item == dummy_scan, timeout=500) + qtbot.wait(200) x_data, y_data = swf.main_curve.getData() np.testing.assert_array_equal(x_data, [10, 20, 30]) diff --git a/tests/unit_tests/test_utils_plot_indicators.py b/tests/unit_tests/test_utils_plot_indicators.py index 6e1b491b2..c156bb38d 100644 --- a/tests/unit_tests/test_utils_plot_indicators.py +++ b/tests/unit_tests/test_utils_plot_indicators.py @@ -8,7 +8,7 @@ @pytest.fixture def plot_widget_with_arrow_item(qtbot, mocked_client): - widget = Waveform(client=mocked_client()) + widget = Waveform(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) @@ -17,7 +17,7 @@ def plot_widget_with_arrow_item(qtbot, mocked_client): @pytest.fixture def plot_widget_with_tick_item(qtbot, mocked_client): - widget = Waveform(client=mocked_client()) + widget = Waveform(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) diff --git a/tests/unit_tests/test_web_console.py b/tests/unit_tests/test_web_console.py index be49cff11..c27eb516a 100644 --- a/tests/unit_tests/test_web_console.py +++ b/tests/unit_tests/test_web_console.py @@ -189,10 +189,10 @@ def test_bec_shell_startup_contains_gui_id(bec_shell_widget): assert bec_shell._is_bec_shell assert bec_shell._unique_id == "bec_shell" - assert bec_shell.startup_cmd == "bec --nogui" + with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None): + assert bec_shell.startup_cmd == "bec --nogui" - with mock.patch.object(bec_shell.bec_dispatcher, "cli_server") as mock_cli_server: - mock_cli_server.gui_id = "test_gui_id" + with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"): assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"