diff --git a/bec_widgets/widgets/services/beamline_states/__init__.py b/bec_widgets/widgets/services/beamline_states/__init__.py index 37a87e41..163a7fe5 100644 --- a/bec_widgets/widgets/services/beamline_states/__init__.py +++ b/bec_widgets/widgets/services/beamline_states/__init__.py @@ -1,6 +1,6 @@ from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( - BeamlineStateList, + BeamlineStateManager, BeamlineStatePill, ) -__all__ = ["BeamlineStateList", "BeamlineStatePill"] +__all__ = ["BeamlineStateManager", "BeamlineStatePill"] diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index 94ee7b78..44b4b9a4 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -1,24 +1,42 @@ from __future__ import annotations +import re import sys from typing import Any +from bec_lib import bl_states from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon from qtpy.QtCore import Qt, QTimer, Signal, Slot from qtpy.QtGui import QColor, QPalette from qtpy.QtWidgets import ( QApplication, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGroupBox, QHBoxLayout, QLabel, + QLineEdit, + QMessageBox, + QPushButton, QScrollArea, QSizePolicy, + QToolButton, QVBoxLayout, QWidget, ) from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox class BeamlineStatePill(BECWidget, QWidget): @@ -291,17 +309,295 @@ class BeamlineStatePill(BECWidget, QWidget): super().cleanup() -class BeamlineStateList(BECWidget, QWidget): - """ - Widget displaying all BEC beamline states as a vertical list of pills. +class AddBeamlineStateDialog(QDialog): + """Dialog for creating supported beamline state configurations.""" - The list subscribes to ``MessageEndpoints.available_beamline_states()`` and creates, updates, - or removes child ``BeamlineStatePill`` widgets as the set of configured states changes. + def __init__(self, parent: QWidget | None = None, client=None) -> None: + super().__init__(parent=parent) + self.setWindowTitle("Add Beamline State") + self._cleaned_up = False + self._config: ( + bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig | None + ) = None + self._auto_generated_name: str | None = None + + self._type_combo = QComboBox(self) + self._type_combo.addItem("Device within limits", "device_within_limits") + self._type_combo.addItem("Shutter", "shutter") + self._type_combo.currentIndexChanged.connect(self._update_field_visibility) + + self._name = QLineEdit(self) + self._name.setPlaceholderText("samx_limits") + self._title = QLineEdit(self) + self._device = DeviceComboBox(parent=self, client=client) + self._signal = SignalComboBox(parent=self, client=client, require_device=True) + self._low_limit = QDoubleSpinBox(self) + self._high_limit = QDoubleSpinBox(self) + self._tolerance = QDoubleSpinBox(self) + self._device.device_selected.connect(self._on_valid_device_selected) + self._device.device_reset.connect(lambda: self._signal.set_device(None)) + + for spin_box in (self._low_limit, self._high_limit): + spin_box.setRange(-1_000_000_000, 1_000_000_000) + spin_box.setDecimals(6) + self._low_limit.setValue(0.0) + self._high_limit.setValue(10.0) + self._tolerance.setRange(0.0, 1_000_000_000) + self._tolerance.setDecimals(6) + self._tolerance.setValue(0.1) + + self._form = QFormLayout() + self._form.addRow("State type", self._type_combo) + self._form.addRow("Name", self._name) + self._form.addRow("Title", self._title) + self._form.addRow("Device", self._device) + self._form.addRow("Signal", self._signal) + self._form.addRow("Low limit", self._low_limit) + self._form.addRow("High limit", self._high_limit) + self._form.addRow("Tolerance", self._tolerance) + + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(self._form) + layout.addWidget(self._buttons) + self.setLayout(layout) + self._update_field_visibility() + + def config(self) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig: + state_type = self._type_combo.currentData() + name = self._state_name() + title = self._optional_text(self._title) + device = self._selected_device() + signal = self._selected_signal() + + if state_type == "shutter": + return bl_states.DeviceStateConfig(name=name, title=title, device=device, signal=signal) + + return bl_states.DeviceWithinLimitsStateConfig( + name=name, + title=title, + device=device, + signal=signal, + low_limit=self._low_limit.value(), + high_limit=self._high_limit.value(), + tolerance=self._tolerance.value(), + ) + + def accept(self) -> None: + try: + self._config = self.config() + except Exception as exc: + QMessageBox.warning(self, "Invalid Beamline State", str(exc)) + return + super().accept() + + @property + def config_result( + self, + ) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig: + if self._config is None: + raise RuntimeError("Beamline state dialog was not accepted with a valid config.") + return self._config + + def cleanup(self) -> None: + if self._cleaned_up: + return + self._cleaned_up = True + self._device.cleanup() + self._signal.cleanup() + + def done(self, result: int) -> None: + try: + self.cleanup() + finally: + super().done(result) + + def _on_valid_device_selected(self, device: str) -> None: + self._signal.set_device(device) + current_name = self._name.text().strip() + if current_name and current_name != self._auto_generated_name: + return + generated_name = f"{self._normalize_identifier(device)}_{self._state_name_suffix()}" + self._auto_generated_name = generated_name + self._name.setText(generated_name) + + def _update_field_visibility(self) -> None: + show_limits = self._type_combo.currentData() == "device_within_limits" + for widget in (self._low_limit, self._high_limit, self._tolerance): + widget.setVisible(show_limits) + label = self._form.labelForField(widget) + if label is not None: + label.setVisible(show_limits) + + def _state_name(self) -> str: + raw_name = self._name.text().strip() + if not raw_name: + raise ValueError("Name is required.") + name = self._normalize_identifier(raw_name) + self._name.setText(name) + return name + + def _selected_device(self) -> str: + device = self._device.currentText().strip() + if not device: + raise ValueError("Device is required.") + if not self._device.is_valid_input: + raise ValueError(f"Device '{device}' is not available.") + return device + + def _selected_signal(self) -> str | None: + signal = self._signal.get_signal_name().strip() + if not signal: + return None + if not self._signal.is_valid_input: + raise ValueError( + f"Signal '{signal}' is not available for device '{self._device.currentText()}'." + ) + return signal + + @staticmethod + def _optional_text(line_edit: QLineEdit) -> str | None: + value = line_edit.text().strip() + return value or None + + @staticmethod + def _normalize_identifier(value: str) -> str: + name = re.sub(r"\W+", "_", value.strip()) + name = re.sub(r"_+", "_", name).strip("_") + if not name: + raise ValueError("Name must contain at least one letter, number, or underscore.") + if name[0].isdigit(): + name = f"state_{name}" + return name + + def _state_name_suffix(self) -> str: + if self._type_combo.currentData() == "device_within_limits": + return "limits" + return "state" + + +class StateFilterDialog(QDialog): + """Dialog for selecting visible beamline states.""" + + def __init__( + self, + state_configs: dict[str, dict[str, Any]], + selected_state_names: set[str] | None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent=parent) + self.setWindowTitle("Filter Beamline States") + self._checkboxes: dict[str, QCheckBox] = {} + + controls = QHBoxLayout() + select_all = QPushButton("Select all", self) + clear = QPushButton("Clear", self) + select_all.clicked.connect(lambda: self._set_all(True)) + clear.clicked.connect(lambda: self._set_all(False)) + controls.addWidget(select_all) + controls.addWidget(clear) + controls.addStretch(1) + + list_layout = QVBoxLayout() + for name, state in sorted(state_configs.items()): + checkbox = QCheckBox(str(state.get("title") or name), self) + checkbox.setChecked(selected_state_names is None or name in selected_state_names) + checkbox.setToolTip(name) + self._checkboxes[name] = checkbox + list_layout.addWidget(checkbox) + list_layout.addStretch(1) + + box = QGroupBox("Displayed states", self) + box.setLayout(list_layout) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(controls) + layout.addWidget(box) + layout.addWidget(buttons) + self.setLayout(layout) + + def selected_state_names(self) -> set[str] | None: + selected = {name for name, checkbox in self._checkboxes.items() if checkbox.isChecked()} + if selected == set(self._checkboxes): + return None + return selected + + def _set_all(self, checked: bool) -> None: + for checkbox in self._checkboxes.values(): + checkbox.setChecked(checked) + + +class DeviceFilterDialog(QDialog): + """Dialog for filtering beamline states by configured device.""" + + def __init__( + self, + devices: list[str], + selected_devices: set[str] | None, + device_filter_text: str, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent=parent) + self.setWindowTitle("Filter Beamline State Devices") + self._checkboxes: dict[str, QCheckBox] = {} + + self._device_text = QLineEdit(self) + self._device_text.setPlaceholderText("Device name or comma-separated names") + self._device_text.setText(device_filter_text) + + list_layout = QVBoxLayout() + for device in devices: + checkbox = QCheckBox(device, self) + checkbox.setChecked(selected_devices is not None and device in selected_devices) + self._checkboxes[device] = checkbox + list_layout.addWidget(checkbox) + list_layout.addStretch(1) + + box = QGroupBox("Known devices", self) + box.setLayout(list_layout) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(self._device_text) + layout.addWidget(box) + layout.addWidget(buttons) + self.setLayout(layout) + + def selected_devices(self) -> set[str] | None: + selected = {device for device, checkbox in self._checkboxes.items() if checkbox.isChecked()} + return selected or None + + def filter_text(self) -> str: + return self._device_text.text().strip() + + +class BeamlineStateManager(BECWidget, QWidget): + """ + Widget displaying and managing all BEC beamline states. + + The manager subscribes to ``MessageEndpoints.available_beamline_states()`` and creates, + updates, or removes child ``BeamlineStatePill`` widgets as the set of configured states changes. """ PLUGIN = True ICON_NAME = "format_list_bulleted" - USER_ACCESS = ["refresh_states", "remove", "attach", "detach", "screenshot"] + USER_ACCESS = ["refresh_states", "clear_filters", "remove", "attach", "detach", "screenshot"] def __init__( self, @@ -315,15 +611,22 @@ class BeamlineStateList(BECWidget, QWidget): parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs ) self._state_pills: dict[str, BeamlineStatePill] = {} + self._state_configs: dict[str, dict[str, Any]] = {} + self._state_order: list[str] = [] + self._selected_state_names: set[str] | None = None + self._selected_devices: set[str] | None = None + self._device_filter_text = "" + self._hidden_expanded = False - self._empty_label = QLabel("No beamline states available.", self) + self._empty_label = QLabel("No beamline states available.\n Add new state from toolbar or CLI.", self) self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._toolbar = self._create_toolbar() + self._content = QWidget(self) self._content_layout = QVBoxLayout(self._content) self._content_layout.setContentsMargins(0, 0, 0, 0) self._content_layout.setSpacing(6) - self._content_layout.addWidget(self._empty_label) self._content_layout.addStretch(1) self._scroll_area = QScrollArea(self) @@ -334,7 +637,21 @@ class BeamlineStateList(BECWidget, QWidget): layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) layout.setSpacing(6) + layout.addWidget(self._toolbar) + layout.addWidget(self._empty_label) layout.addWidget(self._scroll_area) + self._hidden_summary = QToolButton(self) + self._hidden_summary.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self._hidden_summary.setCheckable(True) + self._hidden_summary.clicked.connect(self._toggle_hidden_states) + layout.addWidget(self._hidden_summary) + + self._hidden_content = QWidget(self) + self._hidden_content_layout = QVBoxLayout(self._hidden_content) + self._hidden_content_layout.setContentsMargins(0, 0, 0, 0) + self._hidden_content_layout.setSpacing(6) + self._hidden_content_layout.addStretch(1) + layout.addWidget(self._hidden_content) self.setLayout(layout) self.bec_dispatcher.connect_slot( @@ -343,12 +660,106 @@ class BeamlineStateList(BECWidget, QWidget): from_start=True, ) self.refresh_states() + self._refresh_hidden_summary() + + def _create_toolbar(self) -> ModularToolBar: + toolbar = ModularToolBar(parent=self) + + add_state = MaterialIconAction("add", "Add beamline state", filled=True, parent=self) + filter_states = MaterialIconAction( + "filter_alt", "Filter displayed states", filled=True, parent=self + ) + filter_devices = MaterialIconAction( + "devices", "Filter displayed devices", filled=True, parent=self + ) + clear_filters = MaterialIconAction( + "filter_alt_off", "Clear beamline state filters", filled=True, parent=self + ) + refresh = MaterialIconAction( + "restart_alt", "Refresh beamline states", filled=True, parent=self + ) + + add_state.action.triggered.connect(self.open_add_state_dialog) + filter_states.action.triggered.connect(self.open_state_filter_dialog) + filter_devices.action.triggered.connect(self.open_device_filter_dialog) + clear_filters.action.triggered.connect(self.clear_filters) + refresh.action.triggered.connect(self.refresh_states) + + toolbar.components.add_safe("add_state", add_state) + toolbar.components.add_safe("filter_states", filter_states) + toolbar.components.add_safe("filter_devices", filter_devices) + toolbar.components.add_safe("clear_filters", clear_filters) + toolbar.components.add_safe("refresh", refresh) + + bundle = ToolbarBundle("beamline_state_manager", toolbar.components) + bundle.add_action("add_state") + bundle.add_action("filter_states") + bundle.add_action("filter_devices") + bundle.add_action("clear_filters") + bundle.add_action("refresh") + toolbar.add_bundle(bundle) + toolbar.show_bundles(["beamline_state_manager"]) + return toolbar @Slot(str) def apply_theme(self, _theme: str) -> None: - self.setStyleSheet("BeamlineStateList { border: none; }") + colors = BeamlineStatePill._state_colors("unknown") + self.setStyleSheet( + "BeamlineStateManager { border: none; }" + "QToolButton#hidden_states_summary {" + f"background-color: {colors['background']};" + f"border: 1px solid {colors['border']};" + "border-radius: 6px;" + "padding: 6px;" + "text-align: left;" + "}" + ) for pill in self._state_pills.values(): pill.apply_theme(_theme) + self._refresh_hidden_summary() + + @Slot() + def open_add_state_dialog(self) -> None: + dialog = AddBeamlineStateDialog(self, client=self.client) + if dialog.exec() != QDialog.Accepted: + return + beamline_states = getattr(self.client, "beamline_states", None) + if beamline_states is None: + QMessageBox.warning( + self, "Cannot Add State", "BEC client has no beamline state manager." + ) + return + try: + beamline_states.add(dialog.config_result) + except Exception as exc: + QMessageBox.warning(self, "Cannot Add State", str(exc)) + + @Slot() + def open_state_filter_dialog(self) -> None: + dialog = StateFilterDialog(self._state_configs, self._selected_state_names, self) + if dialog.exec() != QDialog.Accepted: + return + self._selected_state_names = dialog.selected_state_names() + self._apply_filters() + + @Slot() + def open_device_filter_dialog(self) -> None: + dialog = DeviceFilterDialog( + self._available_devices(), self._selected_devices, self._device_filter_text, self + ) + if dialog.exec() != QDialog.Accepted: + return + self._selected_devices = dialog.selected_devices() + self._device_filter_text = dialog.filter_text() + self._apply_filters() + + @Slot() + def clear_filters(self) -> None: + self._selected_state_names = None + self._selected_devices = None + self._device_filter_text = "" + self._hidden_expanded = False + self._apply_filters() def refresh_states(self) -> None: """Fetch the latest cached available beamline states and update the list immediately.""" @@ -368,7 +779,9 @@ class BeamlineStateList(BECWidget, QWidget): states = content.get("states", []) state_configs = [self._state_config_to_dict(state) for state in states] state_configs = [state for state in state_configs if state.get("name")] - state_names = {str(state["name"]) for state in state_configs} + self._state_configs = {str(state["name"]): state for state in state_configs} + self._state_order = [str(state["name"]) for state in state_configs] + state_names = set(self._state_order) for removed_name in sorted(set(self._state_pills) - state_names): self._remove_pill(removed_name) @@ -381,22 +794,102 @@ class BeamlineStateList(BECWidget, QWidget): continue self._add_pill(name, title=title) - self._empty_label.setVisible(not self._state_pills) + self._apply_filters() def _add_pill(self, name: str, title: str) -> None: pill = BeamlineStatePill( parent=self._content, state_name=name, title=title, client=self.client ) self._state_pills[name] = pill - self._content_layout.insertWidget(max(self._content_layout.count() - 1, 0), pill) def _remove_pill(self, name: str) -> None: pill = self._state_pills.pop(name) pill.cleanup() self._content_layout.removeWidget(pill) + self._hidden_content_layout.removeWidget(pill) pill.setParent(None) pill.deleteLater() + def _apply_filters(self) -> None: + visible_names = [] + hidden_names = [] + for name in self._state_order: + if self._is_state_visible(name): + visible_names.append(name) + else: + hidden_names.append(name) + + self._move_pills_to_layout(visible_names, self._content_layout) + self._move_pills_to_layout(hidden_names, self._hidden_content_layout) + self._empty_label.setVisible(not visible_names) + self._hidden_content.setVisible(self._hidden_expanded and bool(hidden_names)) + self._refresh_hidden_summary(hidden_count=len(hidden_names)) + + def _move_pills_to_layout(self, names: list[str], layout: QVBoxLayout) -> None: + for name in names: + pill = self._state_pills[name] + self._content_layout.removeWidget(pill) + self._hidden_content_layout.removeWidget(pill) + layout.insertWidget(max(layout.count() - 1, 0), pill) + pill.setVisible(True) + + def _is_state_visible(self, name: str) -> bool: + if self._selected_state_names is not None and name not in self._selected_state_names: + return False + + device = self._state_device(self._state_configs.get(name, {})) + if self._selected_devices is not None and device not in self._selected_devices: + return False + + tokens = [ + token.strip().casefold() + for token in self._device_filter_text.split(",") + if token.strip() + ] + if tokens: + if device is None: + return False + device_lower = device.casefold() + if not any(token in device_lower for token in tokens): + return False + return True + + def _toggle_hidden_states(self, checked: bool) -> None: + self._hidden_expanded = checked + self._apply_filters() + + def _refresh_hidden_summary(self, hidden_count: int | None = None) -> None: + if hidden_count is None: + hidden_count = sum(1 for name in self._state_order if not self._is_state_visible(name)) + self._hidden_summary.setObjectName("hidden_states_summary") + self._hidden_summary.setVisible(hidden_count > 0) + self._hidden_summary.setChecked(self._hidden_expanded and hidden_count > 0) + icon_name = "expand_less" if self._hidden_expanded else "expand_more" + self._hidden_summary.setIcon(material_icon(icon_name, convert_to_pixmap=False)) + suffix = "state is" if hidden_count == 1 else "states are" + action = "Hide" if self._hidden_expanded else "Show" + self._hidden_summary.setText( + f"{hidden_count} {suffix} hidden by filters. {action} hidden states." + ) + self._hidden_content.setVisible(self._hidden_expanded and hidden_count > 0) + + def _available_devices(self) -> list[str]: + devices = { + device + for state in self._state_configs.values() + if (device := self._state_device(state)) is not None + } + return sorted(devices) + + @staticmethod + def _state_device(state: dict[str, Any]) -> str | None: + parameters = state.get("parameters") + if isinstance(parameters, dict): + device = parameters.get("device") + else: + device = state.get("device") + return str(device) if device else None + @staticmethod def _state_config_to_dict(state: Any) -> dict[str, Any]: if isinstance(state, dict): @@ -411,6 +904,7 @@ class BeamlineStateList(BECWidget, QWidget): ) for name in list(self._state_pills): self._remove_pill(name) + self._toolbar.components.cleanup() super().cleanup() @@ -432,7 +926,7 @@ if __name__ == "__main__": # pragma: no cover theme_row.addStretch(1) theme_row.addWidget(DarkModeButton(parent=window)) layout.addLayout(theme_row) - layout.addWidget(BeamlineStateList(parent=window)) + layout.addWidget(BeamlineStateManager(parent=window)) window.resize(420, 480) window.show() diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index fe5cbfcc..aed57665 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -1,7 +1,9 @@ from bec_lib import messages +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.services.beamline_states.beamline_state_pill import ( - BeamlineStateList, + AddBeamlineStateDialog, + BeamlineStateManager, BeamlineStatePill, ) @@ -37,8 +39,8 @@ def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client): assert widget.toolTip() == "No state information available." -def test_beamline_state_list_adds_and_removes_pills(qtbot, mocked_client): - widget = BeamlineStateList(client=mocked_client) +def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget) widget.update_available_states( @@ -77,3 +79,113 @@ def test_beamline_state_list_adds_and_removes_pills(qtbot, mocked_client): ) assert sorted(widget._state_pills) == ["limits"] + + +def test_beamline_state_manager_filters_states(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "shutter_open", + "title": "Shutter", + "state_type": "ShutterState", + "parameters": {"device": "samy"}, + }, + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + }, + ] + }, + {}, + ) + + assert isinstance(widget._toolbar, ModularToolBar) + + widget._selected_state_names = {"limits"} + widget._apply_filters() + + assert not widget._hidden_summary.isHidden() + assert "1 state is hidden" in widget._hidden_summary.text() + assert widget._state_pills["limits"].parent() is widget._content + assert widget._state_pills["shutter_open"].parent() is widget._hidden_content + + widget._toggle_hidden_states(True) + + assert not widget._hidden_content.isHidden() + + +def test_beamline_state_manager_filters_devices(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client) + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "samx_limits", + "title": "samx", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + }, + { + "name": "samy_limits", + "title": "samy", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samy"}, + }, + ] + }, + {}, + ) + + widget._device_filter_text = "samx" + widget._apply_filters() + + assert not widget._hidden_summary.isHidden() + assert "1 state is hidden" in widget._hidden_summary.text() + assert widget._available_devices() == ["samx", "samy"] + + +def test_add_beamline_state_dialog_uses_device_signal_widgets_and_normalizes_name( + qtbot, mocked_client +): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + + dialog._type_combo.setCurrentIndex(1) + dialog._name.setText("samx-limits") + dialog._title.setText("samx-limits-15") + dialog._device.set_device("samx") + dialog._signal.set_signal("samx") + dialog._high_limit.setValue(15.0) + + config = dialog.config() + + assert config.name == "samx_limits" + assert config.title == "samx-limits-15" + assert config.device == "samx" + assert config.signal == "samx" + assert config.low_limit == 0.0 + assert config.high_limit == 15.0 + + +def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection( + qtbot, mocked_client +): + dialog = AddBeamlineStateDialog(client=mocked_client) + qtbot.addWidget(dialog) + + dialog._type_combo.setCurrentIndex(1) + dialog._device.setCurrentText("s") + + assert dialog._name.text() == "" + + dialog._device.set_device("samx") + + assert dialog._name.text() == "samx_limits"