diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index bb93bf60..796d2dba 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -9,9 +9,12 @@ from bec_lib.logger import bec_logger from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_qthemes import material_icon from pyqtgraph import SignalProxy -from qtpy.QtCore import QThreadPool, Signal +from qtpy.QtCore import Qt, QThreadPool, Signal from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( + DeviceFormDialog, +) from bec_widgets.utils.bec_signal_proxy import cleanup_signal_proxy from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot @@ -19,8 +22,8 @@ from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames from bec_widgets.utils.rpc_register import RPCRegister from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem -from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DirectUpdateDeviceConfigDialog, +from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( + CommunicateConfigAction, ) from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon @@ -109,10 +112,27 @@ class DeviceBrowser(BECWidget, QWidget): self.ui.import_button, "input", self._load_from_file, "append/merge config from file" ) + @SafeSlot() def _create_add_dialog(self): - dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add") + dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device") + dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + dialog.setWindowTitle("Add new device") + dialog.accepted_data.connect(self._add_device) dialog.open() + @SafeSlot(dict, int, int, str, str, popup_error=True) + def _add_device(self, config: dict, *_): + if (name := config.get("name")) in self.dev: + raise ValueError(f"Can't add {name}: a device with this name already exists!") + logger.info(f"Sending request to add device: {config}") + adder = CommunicateConfigAction(self._config_helper, None, config, "add") + adder.signals.error.connect(self._add_device_error) + self._q_threadpool.start(adder) + + @SafeSlot(Exception, popup_error=True) + def _add_device_error(self, e: Exception): + raise e + def on_device_update(self, action: ConfigAction, content: dict) -> None: """ Callback for device update events. Triggers the device_update signal. diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index 990f030a..116c782c 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -83,7 +83,8 @@ class CommunicateConfigAction(QRunnable): logger.info("Done cancelling configuration operation") def process_remove_readd(self, dev_name: str): + new_name = self.config.get("name") or dev_name logger.info(f"Removing and readding device: {dev_name}") self.process_simple_action(dev_name, "remove") - self.process_simple_action(dev_name, "add") - logger.info(f"Reinstated {dev_name} successfully!") + self.process_simple_action(new_name, "add") + logger.info(f"Reinstated {new_name} successfully!") diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py deleted file mode 100644 index 53fc1739..00000000 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ /dev/null @@ -1,321 +0,0 @@ -from ast import literal_eval -from typing import Literal - -from bec_lib.atlas_models import Device as DeviceConfigModel -from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS -from bec_lib.config_helper import ConfigHelper -from bec_lib.logger import bec_logger -from pydantic import field_validator -from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore -from qtpy.QtWidgets import ( - QApplication, - QDialog, - QDialogButtonBox, - QLabel, - QStackedLayout, - QVBoxLayout, - QWidget, -) - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.forms_from_types.items import DynamicFormItem -from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( - CommunicateConfigAction, -) -from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( - DeviceConfigForm, -) -from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget - -logger = bec_logger.logger - -_StdBtn = QDialogButtonBox.StandardButton - - -def _try_literal_eval(value: str): - if value == "": - return "" - try: - return literal_eval(value) - except SyntaxError as e: - raise ValueError(f"Entered config value {value} is not a valid python value!") from e - - -class DeviceConfigDialog(QDialog): - RPC = False - applied = Signal() - accepted_data = Signal(dict) - - def __init__( - self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs - ): - - self._initial_config = {} - self._class_deviceconfig_item = class_deviceconfig_item - super().__init__(parent=parent, **kwargs) - - self._container = QStackedLayout() - self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) - - self._layout = QVBoxLayout() - self._data = {} - self._add_form() - self._add_overlay() - self._add_buttons() - self.setWindowTitle("Add new device") - self.setLayout(self._container) - self._overlay_widget.setVisible(False) - self._form._validity.setVisible(True) - self._connect_form() - - def _connect_form(self): - self._form.validity_proc.connect(self.enable_buttons_for_validity) - self._form.validate_form() - - def _add_form(self): - self._form_widget = QWidget() - self._form_widget.setLayout(self._layout) - self._form = DeviceConfigForm() - self._layout.addWidget(self._form) - - self._container.addWidget(self._form_widget) - - def _add_overlay(self): - self._overlay_widget = QWidget() - self._overlay_widget.setStyleSheet("background-color:rgba(128,128,128,128);") - self._overlay_widget.setAutoFillBackground(True) - self._overlay_layout = QVBoxLayout() - self._overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._overlay_widget.setLayout(self._overlay_layout) - - self._spinner = SpinnerWidget(parent=self) - self._spinner.setMinimumSize(QSize(100, 100)) - self._overlay_layout.addWidget(self._spinner) - self._container.addWidget(self._overlay_widget) - - def _add_buttons(self): - self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel) - self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply) - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.reject) - self._layout.addWidget(self.button_box) - - def _fill_form(self): - self._form.set_data(DeviceConfigModel.model_validate(self._initial_config)) - - def updated_config(self): - new_config = self._form.get_form_data() - diff = { - k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k) - } - if self._initial_config.get("deviceConfig") in [{}, None] and new_config.get( - "deviceConfig" - ) in [{}, None]: - diff.pop("deviceConfig", None) - if diff.get("deviceConfig") is not None: - # TODO: special cased in some parts of device manager but not others, should - # be removed in config update as with below issue - diff["deviceConfig"].pop("device_access", None) - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - diff["deviceConfig"] = { - k: _try_literal_eval(str(v)) for k, v in diff["deviceConfig"].items() if k != "" - } - - # Due to above issues, if deviceConfig changes we must remove and recreate the device - so we need the whole config - if "deviceConfig" in diff: - new_config["deviceConfig"] = diff["deviceConfig"] - return new_config - return diff - - @SafeSlot(bool) - def enable_buttons_for_validity(self, valid: bool): - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]: - button.setEnabled(valid) - button.setToolTip(self._form._validity_message.toolTip()) - - def _process_action(self): - self.accepted_data.emit(self._form.get_form_data()) - - def get_data(self): - return self._data - - @SafeSlot(popup_error=True) - def apply(self): - self._process_action() - self.applied.emit() - - @SafeSlot(popup_error=True) - def accept(self): - self._process_action() - return super().accept() - - -class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): - def __init__( - self, - *, - parent=None, - device: str | None = None, - config_helper: ConfigHelper | None = None, - action: Literal["update"] | Literal["add"] = "update", - threadpool: QThreadPool | None = None, - **kwargs, - ): - """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model - for device specification in bec_lib.atlas_models. - - Args: - parent (QObject): the parent QObject - device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. - config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. - action (Literal["update", "add"]): the action which the form should perform on application or acceptance. - """ - self._device = device - self._q_threadpool = threadpool or QThreadPool() - self._config_helper = config_helper or ConfigHelper( - self.client.connector, self.client._service_name - ) - super().__init__(parent=parent, **kwargs) - self.get_bec_shortcuts() - self._action: Literal["update", "add"] = action - user_warning = QLabel( - "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" - "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." - ) - user_warning.setWordWrap(True) - user_warning.setStyleSheet("QLabel { color: red; }") - self._layout.insertWidget(0, user_warning) - self.setWindowTitle( - f"Edit config for: {device}" if action == "update" else "Add new device" - ) - - if self._action == "update": - self._modify_for_update() - self._form.validity_proc.disconnect(self.enable_buttons_for_validity) - else: - self._set_schema_to_check_devices() - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - # self._form._validity.setVisible(True) - - def _modify_for_update(self): - for row in self._form.enumerate_form_widgets(): - if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE: - row.widget._set_pretty_display() - if self._device in self.dev: - self._fetch_config() - self._fill_form() - self._form._validity.setVisible(False) - - def _set_schema_to_check_devices(self): - class _NameValidatedConfigModel(DeviceConfigModel): - @field_validator("name") - @staticmethod - def _validate_name(value: str, *_): - if not value.isidentifier(): - raise ValueError( - f"Invalid device name: {value}. Device names must be valid Python identifiers." - ) - if value in self.dev: - raise ValueError(f"A device with name {value} already exists!") - return value - - self._form.set_schema(_NameValidatedConfigModel) - - def _fetch_config(self): - if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore - self._initial_config = device._config - - def _process_action(self): - updated_config = self.updated_config() - if self._action == "add": - if self.dev is not None and (name := updated_config.get("name")) in self.dev: - raise ValueError( - f"Can't create a new device with the same name as already existing device {name}!" - ) - self._proc_device_config_change(updated_config) - else: - if updated_config == {}: - logger.info("No changes made to device config") - return - self._proc_device_config_change(updated_config) - - def _proc_device_config_change(self, config: dict): - logger.info(f"Sending request to {self._action} device config: {config}") - - self._start_waiting_display() - communicate_update = CommunicateConfigAction( - self._config_helper, self._device, config, self._action - ) - communicate_update.signals.error.connect(self.update_error) - communicate_update.signals.done.connect(self.update_done) - self._q_threadpool.start(communicate_update) - - @SafeSlot() - def update_done(self): - self._stop_waiting_display() - if self._action == "update": - self._fetch_config() - self._fill_form() - - @SafeSlot(Exception, popup_error=True) - def update_error(self, e: Exception): - self._stop_waiting_display() - if self._action == "update": - self._fetch_config() - self._fill_form() - raise e - - def _start_waiting_display(self): - self._overlay_widget.setVisible(True) - self._spinner.start() - - def _stop_waiting_display(self): - self._overlay_widget.setVisible(False) - self._spinner.stop() - - -def main(): # pragma: no cover - import sys - - from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget - - from bec_widgets.utils.colors import apply_theme - - dialog = None - - app = QApplication(sys.argv) - apply_theme("light") - widget = QWidget() - widget.setLayout(layout := QVBoxLayout()) - - device = QLineEdit() - layout.addWidget(device) - - def _destroy_dialog(*_): - nonlocal dialog - dialog = None - - def accept(*args): - logger.success(f"submitted device config form {dialog} {args}") - _destroy_dialog() - - def _show_dialog(*_): - nonlocal dialog - if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DirectUpdateDeviceConfigDialog(**kwargs) # type: ignore - dialog.accepted.connect(accept) - dialog.rejected.connect(_destroy_dialog) - dialog.open() - - button = QPushButton("Show device dialog") - layout.addWidget(button) - button.clicked.connect(_show_dialog) - widget.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - main() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py deleted file mode 100644 index a6585f5f..00000000 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from functools import partial - -from bec_lib.atlas_models import Device as DeviceConfigModel -from pydantic import BaseModel - -from bec_widgets.utils.colors import get_theme_name -from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem -from bec_widgets.utils.forms_from_types.items import ( - DEFAULT_WIDGET_TYPES, - BoolFormItem, - BoolToggleFormItem, - DictFormItem, - FormItemSpec, -) - - -class DeviceConfigForm(PydanticModelForm): - RPC = False - PLUGIN = False - - def __init__( - self, - parent=None, - client=None, - pretty_display=False, - class_deviceconfig_item: type[BaseModel] | None = None, - **kwargs, - ): - super().__init__( - parent=parent, - data_model=DeviceConfigModel, - pretty_display=pretty_display, - client=client, - **kwargs, - ) - self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item - self._widget_types = DEFAULT_WIDGET_TYPES.copy() - self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem) - self._widget_types["optional_bool"] = ( - lambda spec: spec.item_type == bool | None, - BoolFormItem, - ) - pred, _ = self._widget_types["dict"] - self._widget_types["dict"] = pred, self._custom_device_config_item - self._validity.setVisible(True) - self.populate() - - def _post_init(self): ... - - def _custom_device_config_item(self, spec: FormItemSpec): - if spec.name != "deviceConfig": - return DictFormItem - if self._class_deviceconfig_item is not None: - return partial(PydanticModelFormItem, model=self._class_deviceconfig_item) - return DictFormItem - - def set_pretty_display_theme(self, theme: str | None = None): - if theme is None: - theme = get_theme_name() - self.setStyleSheet(styles.pretty_display_theme(theme)) - self._validity.setVisible(False) - - def get_form_data(self): - """Get the entered metadata as a dict.""" - return self._md_schema.model_validate(super().get_form_data()).model_dump() - - def set_schema(self, schema: type[BaseModel]): - if not issubclass(schema, DeviceConfigModel): - raise TypeError("This class doesn't support changing the schema") - super().set_schema(schema) - - def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type - super().set_data(data) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index 45f233cb..38065864 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +import yaml from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.config_helper import ConfigHelper from bec_lib.devicemanager import DeviceContainer @@ -9,20 +10,26 @@ from bec_lib.logger import bec_logger from bec_lib.messages import ConfigAction from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, QSize, Qt, QThreadPool, Signal -from qtpy.QtGui import QDrag -from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget +from qtpy.QtGui import QDrag, QFontDatabase +from qtpy.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QSizePolicy, + QTabWidget, + QToolButton, + QVBoxLayout, + QWidget, +) +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( + DeviceFormDialog, +) from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.expandable_frame import ExpandableGroupFrame from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) -from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DirectUpdateDeviceConfigDialog, -) -from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( - DeviceConfigForm, -) from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import ( SignalDisplay, ) @@ -33,6 +40,42 @@ if TYPE_CHECKING: # pragma: no cover logger = bec_logger.logger +# Changing any of these means the device server has to remove and recreate the device +_RECREATE_KEYS = {"name", "deviceClass", "deviceConfig"} + + +def config_to_yaml(config: DeviceConfigModel) -> str: + """Format a device config as YAML for display, with the name and class first. + + Args: + config (DeviceConfigModel): the device config to format. + + Returns: + str: the YAML text. + """ + data = config.model_dump(mode="json", exclude_none=True) + ordered = {key: data.pop(key) for key in ("name", "deviceClass") if key in data} + return yaml.safe_dump({**ordered, **data}, sort_keys=False, default_flow_style=False) + + +def config_change(current: dict, edited: dict) -> dict: + """Work out what to send to BEC for an edit of a device config. + + Args: + current (dict): the config of the device as it is in BEC. + edited (dict): the config from the edit dialog. Keys it leaves out keep their current value. + + Returns: + dict: an empty dict if nothing changed, the whole config if the device must be recreated, + otherwise only the changed entries. + """ + current_dump = DeviceConfigModel.model_validate(current).model_dump() + new_dump = DeviceConfigModel.model_validate({**current, **edited}).model_dump() + diff = {k: v for k, v in new_dump.items() if current_dump.get(k) != v} + if _RECREATE_KEYS & diff.keys(): + return new_dump + return diff + class DeviceItem(ExpandableGroupFrame): RPC = False @@ -91,16 +134,35 @@ class DeviceItem(ExpandableGroupFrame): @SafeSlot() def _create_edit_dialog(self): - dialog = DirectUpdateDeviceConfigDialog( - parent=self, - device=self.device, - config_helper=self._config_helper, - threadpool=self._q_threadpool, - ) - dialog.accepted.connect(self._reload_config) - dialog.applied.connect(self._reload_config) + dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes") + dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + dialog.setWindowTitle(f"Edit config for: {self.device}") + dialog.set_device_config(self._current_config()) + dialog.accepted_data.connect(self._apply_edited_config) dialog.open() + @SafeSlot(dict, int, int, str, str, popup_error=True) + def _apply_edited_config(self, config: dict, *_): + change = config_change(self._current_config(), config) + if not change: + logger.info(f"No changes made to the config of {self.device}") + return + new_name = change.get("name", self.device) + if new_name != self.device and new_name in self.dev: + raise ValueError(f"Can't rename {self.device}: a device named {new_name} exists!") + logger.info(f"Sending request to update device config of {self.device}: {change}") + updater = CommunicateConfigAction(self._config_helper, self.device, change, "update") + updater.signals.error.connect(self._update_error) + updater.signals.done.connect( + self._deletion_done if new_name != self.device else self._reload_config + ) + self._q_threadpool.start(updater) + + @SafeSlot(Exception, popup_error=True) + def _update_error(self, e: Exception): + self._reload_config() + raise e + @SafeSlot() def _delete_device(self): self.expanded = False @@ -122,18 +184,21 @@ class DeviceItem(ExpandableGroupFrame): def switch_expanded_state(self): if not self.expanded and not self._expanded_first_time: self._expanded_first_time = True - self.form = DeviceConfigForm(parent=self, pretty_display=True) - self._form_page_layout.addWidget(self.form) + self.config_view = QLabel(parent=self) + self.config_view.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + self.config_view.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.config_view.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + # long values are clipped instead of widening the whole device list + self.config_view.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self._form_page_layout.addWidget(self.config_view) self.signals = SignalDisplay(parent=self, device=self.device) self._signal_page_layout.addWidget(self.signals) self._reload_config() self.broadcast_size_hint.emit(self.sizeHint()) super().switch_expanded_state() if self._expanded_first_time: - self.form.adjustSize() + self.config_view.adjustSize() self.updateGeometry() - if self._expanded: - self.form.set_pretty_display_theme() self.adjustSize() self.broadcast_size_hint.emit(self.sizeHint()) @@ -149,14 +214,17 @@ class DeviceItem(ExpandableGroupFrame): @SafeSlot(popup_error=True) def _reload_config(self, *_): - self.set_display_config(self.dev[self.device]._config) + self.set_display_config(self._current_config()) + + def _current_config(self) -> dict: + return self.dev[self.device]._config # pylint: disable=protected-access def set_display_config(self, config_dict: dict): """Set the displayed information from a device config dict, which must conform to the bec_lib.atlas_models.Device config model.""" self._data = DeviceConfigModel.model_validate(config_dict) if self._expanded_first_time: - self.form.set_data(self._data) + self.config_view.setText(config_to_yaml(self._data)) def mousePressEvent(self, event: QMouseEvent) -> None: super().mousePressEvent(event) @@ -187,9 +255,6 @@ if __name__ == "__main__": # pragma: no cover from qtpy.QtWidgets import QApplication - from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( - DeviceConfigForm, - ) from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton app = QApplication(sys.argv) diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 17ed83b4..d939b98f 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -2,14 +2,19 @@ from typing import TYPE_CHECKING from unittest import mock import pytest +from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.device import Device from qtpy.QtCore import QPoint, Qt -from qtpy.QtWidgets import QTabWidget +from qtpy.QtWidgets import QDialog, QLabel, QTabWidget +from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( + DeviceFormDialog, +) from bec_widgets.tests.client_mocks import mocked_client from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser -from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( - DeviceConfigForm, +from bec_widgets.widgets.services.device_browser.device_item.device_item import ( + config_change, + config_to_yaml, ) from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import ( SignalDisplay, @@ -93,13 +98,12 @@ def test_device_item_expansion(device_browser, qtbot): tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget() qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100) qtbot.waitUntil( - lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), DeviceConfigForm), - timeout=100, + lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), QLabel), timeout=100 ) - form = tab_widget.widget(0).layout().itemAt(0).widget() + config_view: QLabel = tab_widget.widget(0).layout().itemAt(0).widget() assert widget.expanded - assert (name_field := form.widget_dict.get("name")) is not None - qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500) + qtbot.waitUntil(lambda: config_view.text().startswith("name: aptrx\n"), timeout=500) + assert "deviceClass: ophyd_devices.SimPositioner" in config_view.text() qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) assert not widget.expanded @@ -199,3 +203,143 @@ def test_device_browser_cleanup_detaches_filter_proxy(device_browser: DeviceBrow update_filter.assert_not_called() assert not proxy.timer.timer.isActive() assert proxy.args is None + + +_BASIC_CONFIG = { + "name": "test_device", + "enabled": True, + "deviceClass": "TestDevice", + "readoutPriority": "monitored", + "deviceConfig": {"param": 1}, +} + + +@pytest.mark.parametrize( + ["edited", "expected_keys"], + [ + ({}, set()), + (_BASIC_CONFIG, set()), + ({"readOnly": False}, set()), + ({"readOnly": True}, {"readOnly"}), + ({"readOnly": True, "description": "test"}, {"readOnly", "description"}), + ({"deviceTags": ["tag"]}, {"deviceTags"}), + ({"deviceConfig": {"param": 2}}, "all"), + ({"deviceClass": "OtherDevice"}, "all"), + ({"name": "renamed"}, "all"), + ], +) +def test_config_change(edited, expected_keys): + change = config_change(_BASIC_CONFIG, edited) + if expected_keys == "all": + assert change == DeviceConfigModel.model_validate({**_BASIC_CONFIG, **edited}).model_dump() + else: + assert set(change) == expected_keys + + +def test_config_to_yaml_puts_name_and_class_first(): + text = config_to_yaml(DeviceConfigModel.model_validate(_BASIC_CONFIG)) + assert text.splitlines()[:2] == ["name: test_device", "deviceClass: TestDevice"] + assert " param: 1" in text + + +def _opened_dialog(open_dialog) -> DeviceFormDialog: + opened = [] + with mock.patch.object(QDialog, "open", lambda dialog: opened.append(dialog)): + open_dialog() + (dialog,) = opened + assert isinstance(dialog, DeviceFormDialog) + return dialog + + +def _first_item(device_browser) -> "DeviceItem": + return device_browser.dev_list.itemWidget(device_browser.dev_list.itemAt(0, 0)) + + +def _run_inline(widget): + widget._q_threadpool = mock.MagicMock() + widget._q_threadpool.start.side_effect = lambda runnable: runnable.run() + widget._config_helper = mock.MagicMock() + + +def test_add_dialog_sends_add_request(device_browser, qtbot): + dialog = _opened_dialog(device_browser._create_add_dialog) + _run_inline(device_browser) + config = {**_BASIC_CONFIG, "name": "new_device"} + dialog.accepted_data.emit(config, 0, 0, "", "") + device_browser._config_helper.send_config_request.assert_called_once_with( + action="add", config={"new_device": config}, wait_for_response=False + ) + dialog.close() + + +def test_add_dialog_refuses_existing_name(device_browser, qtbot): + dialog = _opened_dialog(device_browser._create_add_dialog) + _run_inline(device_browser) + with mock.patch( + "bec_widgets.utils.error_popups._ErrorPopupUtility.custom_exception_hook" + ) as mock_hook: + dialog.accepted_data.emit({**_BASIC_CONFIG, "name": "samx"}, 0, 0, "", "") + assert isinstance(mock_hook.call_args[0][1], ValueError) + device_browser._config_helper.send_config_request.assert_not_called() + dialog.close() + + +def test_edit_dialog_is_prefilled(device_browser, qtbot): + widget = _first_item(device_browser) + dialog = _opened_dialog(widget._create_edit_dialog) + config = dialog._device_config_template.get_config_fields() + assert config["name"] == widget.device + assert config["deviceClass"] == device_browser.dev[widget.device]._config["deviceClass"] + dialog.close() + + +def test_edit_dialog_sends_only_changes(device_browser, qtbot): + widget = _first_item(device_browser) + dialog = _opened_dialog(widget._create_edit_dialog) + _run_inline(widget) + config = {**dialog._device_config_template.get_config_fields(), "readOnly": True} + dialog.accepted_data.emit(config, 0, 0, "", "") + widget._config_helper.send_config_request.assert_called_once_with( + action="update", config={widget.device: {"readOnly": True}}, wait_for_response=False + ) + dialog.close() + + +def test_edit_dialog_without_changes_sends_nothing(device_browser, qtbot): + widget = _first_item(device_browser) + dialog = _opened_dialog(widget._create_edit_dialog) + _run_inline(widget) + dialog.accepted_data.emit(dialog._device_config_template.get_config_fields(), 0, 0, "", "") + widget._config_helper.send_config_request.assert_not_called() + dialog.close() + + +def test_edit_dialog_rename_recreates_device(device_browser, qtbot): + widget = _first_item(device_browser) + old_name = widget.device + dialog = _opened_dialog(widget._create_edit_dialog) + _run_inline(widget) + config = {**dialog._device_config_template.get_config_fields(), "name": "renamed_device"} + dialog.accepted_data.emit(config, 0, 0, "", "") + calls = widget._config_helper.send_config_request.call_args_list + assert [(c.kwargs["action"], list(c.kwargs["config"])) for c in calls] == [ + ("remove", [old_name]), + ("add", ["renamed_device"]), + ] + qtbot.waitUntil(lambda: old_name not in device_browser.dev_list._item_dict, timeout=1000) + dialog.close() + + +def test_edit_dialog_rename_refuses_existing_name(device_browser, qtbot): + widget = _first_item(device_browser) + other = next(name for name in device_browser.dev if name != widget.device) + dialog = _opened_dialog(widget._create_edit_dialog) + _run_inline(widget) + config = {**dialog._device_config_template.get_config_fields(), "name": other} + with mock.patch( + "bec_widgets.utils.error_popups._ErrorPopupUtility.custom_exception_hook" + ) as mock_hook: + dialog.accepted_data.emit(config, 0, 0, "", "") + assert isinstance(mock_hook.call_args[0][1], ValueError) + widget._config_helper.send_config_request.assert_not_called() + dialog.close() diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py deleted file mode 100644 index dde0876a..00000000 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ /dev/null @@ -1,169 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from bec_lib.atlas_models import Device as DeviceConfigModel -from qtpy.QtWidgets import QDialogButtonBox, QPushButton - -from bec_widgets.utils.forms_from_types.items import StrFormItem -from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DirectUpdateDeviceConfigDialog, - _try_literal_eval, -) - -_BASIC_CONFIG = { - "name": "test_device", - "enabled": True, - "deviceClass": "TestDevice", - "readoutPriority": "monitored", -} - - -@pytest.fixture -def mock_client(): - mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump()) - mock_client = MagicMock() - mock_client.device_manager.devices = {"test_device": mock_device} - return mock_client - - -@pytest.fixture -def update_dialog(mock_client, qtbot): - """Fixture to create a DeviceConfigDialog instance.""" - update_dialog = DirectUpdateDeviceConfigDialog( - device="test_device", config_helper=MagicMock(), client=mock_client - ) - qtbot.addWidget(update_dialog) - return update_dialog - - -@pytest.fixture -def add_dialog(mock_client, qtbot): - """Fixture to create a DeviceConfigDialog instance.""" - add_dialog = DirectUpdateDeviceConfigDialog( - device=None, config_helper=MagicMock(), client=mock_client, action="add" - ) - qtbot.addWidget(add_dialog) - return add_dialog - - -def test_initialization(update_dialog): - assert update_dialog._device == "test_device" - assert update_dialog._container.count() == 2 - - -def test_fill_form(update_dialog): - with patch.object(update_dialog._form, "set_data") as mock_set_data: - update_dialog._fill_form() - mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG)) - - -def test_updated_config(update_dialog): - """Test that updated_config returns the correct changes.""" - update_dialog._initial_config = {"key1": "value1", "key2": "value2"} - with patch.object( - update_dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"} - ): - updated = update_dialog.updated_config() - assert updated == {"key2": "new_value"} - - -def test_apply(update_dialog): - with patch.object(update_dialog, "_process_action") as mock_process_update: - update_dialog.apply() - mock_process_update.assert_called_once() - - -def test_accept(update_dialog): - with ( - patch.object(update_dialog, "_process_action") as mock_process_update, - patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept, - ): - update_dialog.accept() - mock_process_update.assert_called_once() - mock_parent_accept.assert_called_once() - - -def test_waiting_display(update_dialog, qtbot): - with ( - patch.object(update_dialog._spinner, "start") as mock_spinner_start, - patch.object(update_dialog._spinner, "stop") as mock_spinner_stop, - ): - update_dialog.show() - update_dialog._start_waiting_display() - qtbot.waitUntil(update_dialog._overlay_widget.isVisible, timeout=100) - mock_spinner_start.assert_called_once() - mock_spinner_stop.assert_not_called() - update_dialog._stop_waiting_display() - qtbot.waitUntil(lambda: not update_dialog._overlay_widget.isVisible(), timeout=100) - mock_spinner_stop.assert_called_once() - - -def test_update_cycle(update_dialog): - update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}} - - def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None): - device = update_dialog.client.device_manager.devices["test_device"] - device._config = {**device._config, **config["test_device"]} # type: ignore - - update_dialog._q_threadpool = MagicMock() - update_dialog._q_threadpool.start.side_effect = lambda runnable: runnable.run() - - update_dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send) - for item in update_dialog._form.enumerate_form_widgets(): - if (val := update.get(item.label.property("_model_field_name"))) is not None: - item.widget.setValue(val) - - assert update_dialog.updated_config() == update - update_dialog.apply() - update_dialog._q_threadpool.start.assert_called_once() - - update_dialog._config_helper.send_config_request.assert_called_with( - action="update", config={"test_device": update}, wait_for_response=False - ) - - -@pytest.mark.parametrize( - ["changes", "result"], - [ - ({}, {}), - ({"readOnly": True}, {"readOnly": True}), - ({"readOnly": False}, {}), - ({"readOnly": True, "description": "test"}, {"readOnly": True, "description": "test"}), - ( - {"deviceConfig": {"param1": "'val1'"}}, - DeviceConfigModel( - enabled=True, - deviceClass="TestDevice", - deviceConfig={"param1": "val1"}, - readoutPriority="monitored", - name="test_device", - ).model_dump(), - ), - ({"deviceConfig": {}}, {}), - ], -) -def test_update_with_modified_deviceconfig(update_dialog, changes, result): - for k, v in changes.items(): - update_dialog._form.widget_dict[k].setValue(v) - assert update_dialog.updated_config() == result - - -def test_add_form_init_without_name(add_dialog, qtbot): - assert (name_widget := add_dialog._form.widget_dict.get("name")) is not None - assert isinstance(name_widget, StrFormItem) - assert name_widget.getValue() is None - - -def test_add_form_validates_and_disables_on_init(add_dialog, qtbot): - assert (ok_button := add_dialog.button_box.button(QDialogButtonBox.Ok)) is not None - assert isinstance(ok_button, QPushButton) - assert not ok_button.isEnabled() - - -def test_try_literal_eval(): - assert _try_literal_eval("") == "" - assert _try_literal_eval("[1, 2, 3]") == [1, 2, 3] - assert _try_literal_eval('"[,,]"') == "[,,]" - with pytest.raises(ValueError) as e: - _try_literal_eval("[,,]") - assert e.match("Entered config value [,,]")