diff --git a/bec_widgets/widgets/monitor/config_dialog.py b/bec_widgets/widgets/monitor/config_dialog.py index e04ac736..1641c11c 100644 --- a/bec_widgets/widgets/monitor/config_dialog.py +++ b/bec_widgets/widgets/monitor/config_dialog.py @@ -13,6 +13,11 @@ from qtpy.QtWidgets import ( ) from bec_widgets.utils.yaml_dialog import load_yaml, save_yaml +from bec_widgets.validation import MonitorConfigValidator +from pydantic import ValidationError +from qtpy.QtWidgets import QApplication, QMessageBox +from bec_widgets.utils.bec_dispatcher import BECDispatcher + current_path = os.path.dirname(__file__) Ui_Form, BaseClass = uic.loadUiType(os.path.join(current_path, "config_dialog.ui")) @@ -168,10 +173,25 @@ CONFIG_SCAN_MODE = { class ConfigDialog(QWidget, Ui_Form): config_updated = pyqtSignal(dict) - def __init__(self, default_config=None): + def __init__( + self, + client=None, + default_config=None, + skip_validation: bool = False, + ): super(ConfigDialog, self).__init__() self.setupUi(self) + # Client + bec_dispatcher = BECDispatcher() + self.client = bec_dispatcher.client if client is None else client + self.dev = self.client.device_manager.devices + + # Init validator + self.skip_validation = skip_validation + if self.skip_validation is False: + self.validator = MonitorConfigValidator(self.dev) + # Connect the Ok/Apply/Cancel buttons self.pushButton_ok.clicked.connect(self.apply_and_close) self.pushButton_apply.clicked.connect(self.apply_config) @@ -522,8 +542,45 @@ class ConfigDialog(QWidget, Ui_Form): def apply_and_close(self): new_config = self.apply_config() - self.config_updated.emit(new_config) - self.close() + if self.skip_validation is True: + self.config_updated.emit(new_config) + self.close() + else: + try: + validated_config = self.validator.validate_monitor_config(new_config) + approved_config = validated_config.model_dump() + self.config_updated.emit(approved_config) + self.close() + except ValidationError as e: + error_str = str(e) + formatted_error_message = ConfigDialog.format_validation_error(error_str) + + # Display the formatted error message in a popup + QMessageBox.critical(self, "Configuration Error", formatted_error_message) + + @staticmethod + def format_validation_error(error_str: str) -> str: + """ + Format the validation error string to be displayed in a popup. + Args: + error_str(str): Error string from the validation error. + """ + error_lines = error_str.split("\n") + # The first line contains the number of errors. + error_header = f"

{error_lines[0]}


" + + formatted_error_message = error_header + # Skip the first line as it's the header. + error_details = error_lines[1:] + + # Iterate through pairs of lines (each error's two lines). + for i in range(0, len(error_details), 2): + location = error_details[i] + message = error_details[i + 1] if i + 1 < len(error_details) else "" + + formatted_error_message += f"

{location}
{message}


" + + return formatted_error_message if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/monitor/monitor.py b/bec_widgets/widgets/monitor/monitor.py index 4b7d70fd..840f0dd7 100644 --- a/bec_widgets/widgets/monitor/monitor.py +++ b/bec_widgets/widgets/monitor/monitor.py @@ -309,7 +309,7 @@ class BECMonitor(pg.GraphicsLayoutWidget): self.enable_crosshair = enable_crosshair # Displayed Data - self.database = {} + self.database = None self.crosshairs = None self.plots = None @@ -352,6 +352,9 @@ class BECMonitor(pg.GraphicsLayoutWidget): # Initialize the UI self._init_ui(self.plot_settings["num_columns"]) + if self.scanID is not None: + self.replot_last_scan() + def _init_database(self, plot_data_config: dict, source_type_to_init=None) -> dict: """ Initializes or updates the database for the PlotApp. @@ -601,7 +604,9 @@ class BECMonitor(pg.GraphicsLayoutWidget): """Show the configuration dialog.""" from bec_widgets.widgets import ConfigDialog - dialog = ConfigDialog(default_config=self.config) + dialog = ConfigDialog( + client=self.client, default_config=self.config, skip_validation=self.skip_validation + ) dialog.config_updated.connect(self.on_config_update) dialog.show() @@ -772,6 +777,13 @@ class BECMonitor(pg.GraphicsLayoutWidget): else: print(f"No data found for {device_name} {entry}") + def replot_last_scan(self): + """ + Replot the last scan. + """ + self.scan_segment_update() + self.update_plot(source_type="scan_segment") + @pyqtSlot(dict) def on_data_from_redis(self, msg) -> None: """ diff --git a/tests/test_config_dialog.py b/tests/test_config_dialog.py index 6d9bc9c9..48db677a 100644 --- a/tests/test_config_dialog.py +++ b/tests/test_config_dialog.py @@ -1,4 +1,6 @@ import os +from unittest.mock import MagicMock + import yaml import pytest @@ -15,9 +17,73 @@ def load_test_config(config_name): return config +class FakeDevice: + """Fake minimal positioner class for testing.""" + + def __init__(self, name, enabled=True): + self.name = name + self.enabled = enabled + self.signals = {self.name: {"value": 1.0}} + self.description = {self.name: {"source": self.name}} + + def __contains__(self, item): + return item == self.name + + @property + def _hints(self): + return [self.name] + + def set_value(self, fake_value: float = 1.0) -> None: + """ + Setup fake value for device readout + Args: + fake_value(float): Desired fake value + """ + self.signals[self.name]["value"] = fake_value + + def describe(self) -> dict: + """ + Get the description of the device + Returns: + dict: Description of the device + """ + return self.description + + +def get_mocked_device(device_name: str): + """ + Helper function to mock the devices + Args: + device_name(str): Name of the device to mock + """ + return FakeDevice(name=device_name, enabled=True) + + @pytest.fixture(scope="function") -def config_dialog(qtbot): - widget = ConfigDialog() +def mocked_client(): + # Create a dictionary of mocked devices + device_names = ["samx", "gauss_bpm", "gauss_adc1", "gauss_adc2", "gauss_adc3", "bpm4i"] + mocked_devices = {name: get_mocked_device(name) for name in device_names} + + # Create a MagicMock object + client = MagicMock() + + # Mock the device_manager.devices attribute + client.device_manager.devices = MagicMock() + client.device_manager.devices.__getitem__.side_effect = lambda x: mocked_devices.get(x) + client.device_manager.devices.__contains__.side_effect = lambda x: x in mocked_devices + + # Set each device as an attribute of the mock + for name, device in mocked_devices.items(): + setattr(client.device_manager.devices, name, device) + + return client + + +@pytest.fixture(scope="function") +def config_dialog(qtbot, mocked_client): + client = mocked_client + widget = ConfigDialog(client=client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget