mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-05 21:08:40 +02:00
feat(beamline_state_manager): new widget
This commit is contained in:
@@ -32,6 +32,8 @@ _Widgets = {
|
||||
"BECQueue": "BECQueue",
|
||||
"BECShell": "BECShell",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
"BeamlineStateManager": "BeamlineStateManager",
|
||||
"BeamlineStatePill": "BeamlineStatePill",
|
||||
"BecConsole": "BecConsole",
|
||||
"DapComboBox": "DapComboBox",
|
||||
"DeviceBrowser": "DeviceBrowser",
|
||||
@@ -717,6 +719,110 @@ class BaseROI(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class BeamlineStateManager(RPCBase):
|
||||
"""Widget displaying and managing all BEC beamline states."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_pill"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def idle_card_background(self) -> "bool":
|
||||
"""
|
||||
Whether idle collapsed pills keep the status-tinted card background.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_idle_card_background(self, enabled: "bool") -> "None":
|
||||
"""
|
||||
Set whether idle collapsed pills keep the status-tinted card background.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def refresh_states(self) -> "None":
|
||||
"""
|
||||
Fetch the latest cached available beamline states and update the list immediately.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def clear_filters(self) -> "None":
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class BeamlineStatePill(RPCBase):
|
||||
"""Compact widget showing one BEC beamline state."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_pill"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def state_name(self) -> "str | None":
|
||||
"""
|
||||
Name of the BEC beamline state displayed by this pill.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_state_name(self, state_name: "str | None", title: "str | None" = None) -> "None":
|
||||
"""
|
||||
Set the BEC beamline state this pill displays.
|
||||
|
||||
Args:
|
||||
state_name: State name as published by ``AvailableBeamlineStatesMessage``.
|
||||
title: Optional human-readable title for the state.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def remove(self):
|
||||
"""
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
@rpc_timeout(None)
|
||||
@rpc_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
|
||||
|
||||
class BecConsole(RPCBase):
|
||||
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@ designer_plugins = {
|
||||
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
|
||||
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
|
||||
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
|
||||
"BeamlineStateManager": (
|
||||
"bec_widgets.widgets.services.beamline_states.beamline_state_pill",
|
||||
"BeamlineStateManager",
|
||||
),
|
||||
"BeamlineStatePill": (
|
||||
"bec_widgets.widgets.services.beamline_states.beamline_state_pill",
|
||||
"BeamlineStatePill",
|
||||
),
|
||||
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
|
||||
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
|
||||
"ColorButtonNative": (
|
||||
@@ -118,6 +126,8 @@ widget_icons = {
|
||||
"BECShell": "hub",
|
||||
"BECSpinBox": "123",
|
||||
"BECStatusBox": "widgets",
|
||||
"BeamlineStateManager": "format_list_bulleted",
|
||||
"BeamlineStatePill": "info",
|
||||
"BecConsole": "terminal",
|
||||
"ColorButton": "colors",
|
||||
"ColorButtonNative": "colors",
|
||||
|
||||
@@ -385,6 +385,11 @@ class BECDockArea(DockAreaWidget):
|
||||
"bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
|
||||
"sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
|
||||
"log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
|
||||
"beamline_state_manager": (
|
||||
widget_icons["BeamlineStateManager"],
|
||||
"Add Beamline State Manager",
|
||||
"BeamlineStateManager",
|
||||
),
|
||||
}
|
||||
|
||||
# Create expandable menu actions (original behavior)
|
||||
|
||||
@@ -413,6 +413,8 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
apply_theme(theme) # emits theme_updated and applies palette globally
|
||||
|
||||
def event(self, event):
|
||||
if not isinstance(event, QEvent):
|
||||
return False
|
||||
if event.type() == QEvent.Type.StatusTip:
|
||||
return True
|
||||
return super().event(event)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['beamline_state_pill.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStateManager
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BeamlineStateManager' name='beamline_state_manager'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BeamlineStateManagerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = BeamlineStateManager(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BeamlineStateManager.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "beamline_state_manager"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BeamlineStateManager"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{'files': ['beamline_state_pill.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStatePill
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BeamlineStatePill' name='beamline_state_pill'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BeamlineStatePillPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = BeamlineStatePill(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BeamlineStatePill.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "beamline_state_pill"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BeamlineStatePill"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from bec_lib import bl_states
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
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
|
||||
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
|
||||
|
||||
BEAMLINE_STATE_STATUS_LABELS = {
|
||||
"valid": "VALID",
|
||||
"invalid": "INVALID",
|
||||
"warning": "WARNING",
|
||||
"unknown": "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
class AddBeamlineStateDialog(QDialog):
|
||||
"""Dialog for creating supported beamline state configurations."""
|
||||
|
||||
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 = BECSpinBox(self)
|
||||
self._high_limit = BECSpinBox(self)
|
||||
self._tolerance = BECSpinBox(self)
|
||||
self._device.device_selected.connect(self._on_valid_device_selected)
|
||||
self._device.device_reset.connect(self._on_device_reset)
|
||||
|
||||
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)
|
||||
for field in self._input_fields():
|
||||
field.setFixedWidth(280)
|
||||
for spin_box in (self._low_limit, self._high_limit, self._tolerance):
|
||||
spin_box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
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()
|
||||
self.setFixedSize(self.sizeHint().expandedTo(self.minimumSizeHint()))
|
||||
|
||||
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.close()
|
||||
self._device.deleteLater()
|
||||
self._signal.close()
|
||||
self._signal.deleteLater()
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_valid_device_selected(self, device: str) -> None:
|
||||
if self._cleaned_up:
|
||||
return
|
||||
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)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_device_reset(self) -> None:
|
||||
if self._cleaned_up:
|
||||
return
|
||||
self._signal.set_device(None)
|
||||
|
||||
@SafeSlot(int)
|
||||
def _update_field_visibility(self, _index: int = 0) -> 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"
|
||||
|
||||
def _input_fields(self) -> tuple[QWidget, ...]:
|
||||
return (
|
||||
self._type_combo,
|
||||
self._name,
|
||||
self._title,
|
||||
self._device,
|
||||
self._signal,
|
||||
self._low_limit,
|
||||
self._high_limit,
|
||||
self._tolerance,
|
||||
)
|
||||
|
||||
|
||||
class StatusFilterDialog(QDialog):
|
||||
"""Dialog for selecting visible beamline state statuses."""
|
||||
|
||||
def __init__(self, selected_statuses: set[str] | None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Filter Beamline State Status")
|
||||
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 status, label in BEAMLINE_STATE_STATUS_LABELS.items():
|
||||
checkbox = QCheckBox(label, self)
|
||||
checkbox.setChecked(selected_statuses is None or status in selected_statuses)
|
||||
self._checkboxes[status] = checkbox
|
||||
list_layout.addWidget(checkbox)
|
||||
list_layout.addStretch(1)
|
||||
|
||||
box = QGroupBox("Displayed status", 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_statuses(self) -> set[str] | None:
|
||||
selected = {status for status, 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()
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_manager_plugin import (
|
||||
BeamlineStateManagerPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BeamlineStateManagerPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_pill_plugin import (
|
||||
BeamlineStatePillPlugin,
|
||||
)
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BeamlineStatePillPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,641 @@
|
||||
from pathlib import Path
|
||||
|
||||
import shiboken6
|
||||
from bec_lib import messages
|
||||
from qtpy.QtCore import QCoreApplication, QEvent, QPoint, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication, QMessageBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.services.beamline_states import beamline_state_pill as pill_module
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import (
|
||||
BeamlineStateManager,
|
||||
BeamlineStatePill,
|
||||
_BeamlineStatePillHeader,
|
||||
)
|
||||
from bec_widgets.widgets.services.beamline_states.dialogs import AddBeamlineStateDialog
|
||||
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
|
||||
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
class _FakeAccentColors:
|
||||
default = QColor("#0a60ff")
|
||||
success = QColor("#2CA58D")
|
||||
emergency = QColor("#CC181E")
|
||||
warning = QColor("#EAC435")
|
||||
|
||||
|
||||
class _FakeTheme:
|
||||
def __init__(self, theme: str) -> None:
|
||||
self.theme = theme
|
||||
self.accent_colors = _FakeAccentColors()
|
||||
self._colors = {
|
||||
"CARD_BG": "#ffffff" if theme == "light" else "#171a21",
|
||||
"FG": "#151924" if theme == "light" else "#e8ebf1",
|
||||
"BORDER": "#d9dde6" if theme == "light" else "#2a2f3a",
|
||||
"ON_PRIMARY": "#ffffff",
|
||||
"ACCENT_DEFAULT": "#0a60ff" if theme == "light" else "#8ab4f7",
|
||||
}
|
||||
|
||||
def color(self, key: str, fallback: str = "#000000") -> QColor:
|
||||
return QColor(self._colors.get(key, fallback))
|
||||
|
||||
|
||||
def _gradient_alpha(colors: dict[str, str]) -> int:
|
||||
return int(colors["gradient_accent"].rsplit(",", 1)[1].strip(" )"))
|
||||
|
||||
|
||||
def test_beamline_state_pill_updates_from_message(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="shutter_open", title="Shutter", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_state(
|
||||
{"name": "shutter_open", "status": "valid", "label": "Shutter is open."}, {}
|
||||
)
|
||||
|
||||
assert widget.state_name == "shutter_open"
|
||||
assert widget._name_label.text() == "Shutter"
|
||||
assert widget._status_label.text() == "VALID"
|
||||
assert widget._detail_label.text() == "Shutter is open."
|
||||
assert not widget._icon_label.pixmap().isNull()
|
||||
assert widget.toolTip() == "Shutter is open."
|
||||
|
||||
|
||||
def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="shutter_open", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_state(
|
||||
{"name": "other_state", "status": "invalid", "label": "Should be ignored."}, {}
|
||||
)
|
||||
|
||||
assert widget._status_label.text() == "UNKNOWN"
|
||||
assert widget.toolTip() == "No state information available."
|
||||
|
||||
|
||||
def test_beamline_states_init_is_empty():
|
||||
assert Path(pill_module.__file__).with_name("__init__.py").read_text() == ""
|
||||
|
||||
|
||||
def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
widget.set_state_config(
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"device": "samx",
|
||||
"signal": "samx",
|
||||
"low_limit": 0.0,
|
||||
"high_limit": 10.0,
|
||||
"tolerance": 0.1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert widget._settings.isHidden()
|
||||
assert not widget._update_button.isEnabled()
|
||||
assert not widget._revert_button.isEnabled()
|
||||
|
||||
qtbot.mouseClick(widget._header, Qt.MouseButton.LeftButton)
|
||||
widget._high_limit.setValue(20.0)
|
||||
|
||||
assert not widget._settings.isHidden()
|
||||
assert widget._update_button.isEnabled()
|
||||
assert widget._revert_button.isEnabled()
|
||||
assert widget._high_limit.parentWidget().property("beamlineStateDirty") is True
|
||||
assert isinstance(widget._device_edit, DeviceComboBox)
|
||||
assert isinstance(widget._signal_edit, SignalComboBox)
|
||||
assert widget._device_edit.currentText() == "samx"
|
||||
for field in widget._settings_input_fields():
|
||||
assert field.minimumWidth() == widget._SETTINGS_FIELD_WIDTH
|
||||
assert field.sizePolicy().horizontalPolicy() == QSizePolicy.Policy.Expanding
|
||||
assert widget.edited_parameters()["high_limit"] == 20.0
|
||||
|
||||
with qtbot.waitSignal(widget.update_requested) as signal:
|
||||
widget._update_button.click()
|
||||
|
||||
assert signal.args[0] == "limits"
|
||||
assert signal.args[1]["device"] == "samx"
|
||||
assert signal.args[1]["signal"] == "samx"
|
||||
assert signal.args[1]["low_limit"] == 0.0
|
||||
assert signal.args[1]["high_limit"] == 20.0
|
||||
assert signal.args[1]["tolerance"] == 0.1
|
||||
assert not widget._settings.isHidden()
|
||||
|
||||
|
||||
def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
widget.set_state_config(
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {
|
||||
"device": "samx",
|
||||
"signal": "samx",
|
||||
"low_limit": 0.0,
|
||||
"high_limit": 10.0,
|
||||
"tolerance": 0.1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
widget._low_limit.setValue(-5.0)
|
||||
|
||||
assert widget._update_button.isEnabled()
|
||||
assert widget._low_limit.parentWidget().property("beamlineStateDirty") is True
|
||||
|
||||
widget._revert_button.click()
|
||||
|
||||
assert widget._low_limit.value() == 0.0
|
||||
assert not widget._update_button.isEnabled()
|
||||
assert not widget._revert_button.isEnabled()
|
||||
assert widget._low_limit.parentWidget().property("beamlineStateDirty") is False
|
||||
|
||||
|
||||
def test_beamline_state_pill_uses_card_style_when_expanded(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert "#BeamlineStatePill {background: transparent" in widget.styleSheet()
|
||||
assert "#BeamlineStatePill:hover {background: qlineargradient" in widget.styleSheet()
|
||||
|
||||
widget._toggle_expanded()
|
||||
|
||||
assert "#BeamlineStatePill {background: qlineargradient" in widget.styleSheet()
|
||||
assert widget._shadow.isEnabled()
|
||||
|
||||
|
||||
def test_beamline_state_pill_can_keep_idle_background(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert "#BeamlineStatePill {background: transparent" in widget.styleSheet()
|
||||
|
||||
widget.idle_card_background = True
|
||||
|
||||
assert "#BeamlineStatePill {background: transparent" not in widget.styleSheet()
|
||||
|
||||
|
||||
def test_beamline_state_pill_declares_card_style_for_hover(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert "#BeamlineStatePill:hover {background: qlineargradient" in widget.styleSheet()
|
||||
assert not widget._shadow.isEnabled()
|
||||
|
||||
|
||||
def test_beamline_state_pill_light_mode_uses_neutral_card_with_subtle_left_gradient(
|
||||
qtbot, monkeypatch
|
||||
):
|
||||
app = QApplication.instance()
|
||||
monkeypatch.setattr(app, "theme", _FakeTheme("light"), raising=False)
|
||||
|
||||
for status in ("valid", "invalid", "warning"):
|
||||
colors = BeamlineStatePill._state_colors(status)
|
||||
|
||||
assert _gradient_alpha(colors) == 18
|
||||
assert colors["gradient_stop"] == "0.38"
|
||||
assert colors["card_background"] == "#ffffff"
|
||||
assert colors["background"] == "#ffffff"
|
||||
|
||||
|
||||
def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client):
|
||||
widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
widget.set_expanded(True)
|
||||
|
||||
stylesheet = widget.styleSheet()
|
||||
|
||||
assert "QAbstractSpinBox" not in stylesheet
|
||||
assert "QComboBox" not in stylesheet
|
||||
assert "QCheckBox::indicator" not in stylesheet
|
||||
|
||||
|
||||
def test_beamline_state_pill_dark_mode_keeps_existing_gradient_strength(qtbot, monkeypatch):
|
||||
app = QApplication.instance()
|
||||
monkeypatch.setattr(app, "theme", _FakeTheme("dark"), raising=False)
|
||||
|
||||
colors = BeamlineStatePill._state_colors("warning")
|
||||
|
||||
assert _gradient_alpha(colors) == 62
|
||||
assert colors["gradient_stop"] == "0.62"
|
||||
|
||||
|
||||
def test_beamline_state_pill_header_emits_click_without_pointer_move(qtbot):
|
||||
header = _BeamlineStatePillHeader()
|
||||
header.resize(120, 32)
|
||||
qtbot.addWidget(header)
|
||||
|
||||
clicked = []
|
||||
header.clicked.connect(lambda: clicked.append(True))
|
||||
|
||||
qtbot.mousePress(header, Qt.MouseButton.LeftButton, pos=QPoint(8, 8))
|
||||
qtbot.mouseRelease(header, Qt.MouseButton.LeftButton, pos=QPoint(8, 8))
|
||||
|
||||
assert clicked == [True]
|
||||
|
||||
|
||||
def test_beamline_state_pill_header_suppresses_click_after_release_outside(qtbot):
|
||||
header = _BeamlineStatePillHeader()
|
||||
header.resize(120, 32)
|
||||
qtbot.addWidget(header)
|
||||
|
||||
clicked = []
|
||||
header.clicked.connect(lambda: clicked.append(True))
|
||||
|
||||
qtbot.mousePress(header, Qt.MouseButton.LeftButton, pos=QPoint(8, 8))
|
||||
qtbot.mouseRelease(header, Qt.MouseButton.LeftButton, pos=QPoint(140, 8))
|
||||
|
||||
assert clicked == []
|
||||
|
||||
|
||||
def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
messages.BeamlineStateConfig(
|
||||
name="shutter_open", title="Shutter", state_type="ShutterState", parameters={}
|
||||
),
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {},
|
||||
},
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
assert sorted(widget._state_pills) == ["limits", "shutter_open"]
|
||||
assert widget._model.rowCount() == 2
|
||||
assert widget._state_pills["shutter_open"]._name_label.text() == "Shutter"
|
||||
assert not widget._empty_label.isVisible()
|
||||
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
assert sorted(widget._state_pills) == ["limits"]
|
||||
assert widget._model.rowCount() == 1
|
||||
|
||||
|
||||
def test_beamline_state_manager_items_are_not_draggable(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
flags = widget._model.flags(widget._model.index_for_name("limits"))
|
||||
|
||||
assert not flags & Qt.ItemFlag.ItemIsDragEnabled
|
||||
|
||||
|
||||
def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samx"},
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
pill = widget._state_pills["limits"]
|
||||
assert pill._settings.isHidden()
|
||||
|
||||
qtbot.mouseClick(pill._header, Qt.MouseButton.LeftButton)
|
||||
|
||||
assert not pill._settings.isHidden()
|
||||
|
||||
|
||||
def test_beamline_state_manager_preserves_expanded_pill_on_refresh(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
state = {
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samx", "high_limit": 10.0},
|
||||
}
|
||||
widget.update_available_states({"states": [state]}, {})
|
||||
|
||||
widget._state_pills["limits"].set_expanded(True)
|
||||
widget.update_available_states({"states": [state]}, {})
|
||||
|
||||
assert widget._state_pills["limits"].is_expanded()
|
||||
assert not widget._state_pills["limits"]._settings.isHidden()
|
||||
|
||||
|
||||
def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client, idle_card_background=True)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samx"},
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
assert widget._state_pills["limits"].idle_card_background is True
|
||||
|
||||
widget.idle_card_background = False
|
||||
|
||||
assert widget._state_pills["limits"].idle_card_background is False
|
||||
|
||||
|
||||
def test_beamline_state_manager_filters_status(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._state_pills["limits"].update_state(
|
||||
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
|
||||
)
|
||||
widget._state_pills["shutter_open"].update_state(
|
||||
{"name": "shutter_open", "status": "invalid", "label": "Closed."}, {}
|
||||
)
|
||||
widget._selected_statuses = {"valid"}
|
||||
widget._apply_filters()
|
||||
|
||||
assert not widget._hidden_summary.isHidden()
|
||||
assert "1 state is hidden" in widget._hidden_summary.text()
|
||||
assert not widget._view.isRowHidden(widget._model.index_for_name("limits").row())
|
||||
assert widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row())
|
||||
|
||||
widget._hidden_summary.click()
|
||||
|
||||
assert not widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row())
|
||||
assert shiboken6.isValid(widget._state_pills["shutter_open"])
|
||||
|
||||
widget._hidden_summary.click()
|
||||
|
||||
assert widget._view.isRowHidden(widget._model.index_for_name("shutter_open").row())
|
||||
assert shiboken6.isValid(widget._state_pills["shutter_open"])
|
||||
|
||||
|
||||
def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samx"},
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
widget._selected_statuses = {"valid"}
|
||||
widget._state_pills["limits"].update_state(
|
||||
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
|
||||
)
|
||||
|
||||
assert widget._hidden_summary.isHidden()
|
||||
|
||||
widget._state_pills["limits"].update_state(
|
||||
{"name": "limits", "status": "invalid", "label": "Out of limits."}, {}
|
||||
)
|
||||
|
||||
assert not widget._hidden_summary.isHidden()
|
||||
assert widget._view.isRowHidden(widget._model.index_for_name("limits").row())
|
||||
|
||||
|
||||
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_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {
|
||||
"device": "samx",
|
||||
"signal": "samx",
|
||||
"low_limit": 0.0,
|
||||
"high_limit": 10.0,
|
||||
"tolerance": 0.1,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
class StateClient:
|
||||
def __init__(self):
|
||||
self.parameters = None
|
||||
|
||||
def update_parameters(self, **kwargs):
|
||||
self.parameters = kwargs
|
||||
|
||||
class StateManager:
|
||||
def __init__(self):
|
||||
self.limits = StateClient()
|
||||
|
||||
mocked_client.beamline_states = StateManager()
|
||||
pill = widget._state_pills["limits"]
|
||||
pill._high_limit.setValue(20.0)
|
||||
|
||||
assert pill._update_button.isEnabled()
|
||||
|
||||
widget._update_state_parameters("limits", pill.edited_parameters())
|
||||
|
||||
assert mocked_client.beamline_states.limits.parameters == {
|
||||
"title": "Limits",
|
||||
"device": "samx",
|
||||
"signal": "samx",
|
||||
"low_limit": 0.0,
|
||||
"high_limit": 20.0,
|
||||
"tolerance": 0.1,
|
||||
}
|
||||
assert not pill._update_button.isEnabled()
|
||||
assert pill._high_limit.parentWidget().property("beamlineStateDirty") is False
|
||||
|
||||
|
||||
def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
class StateManager:
|
||||
def __init__(self):
|
||||
self.deleted = None
|
||||
|
||||
def delete(self, state_name):
|
||||
self.deleted = state_name
|
||||
|
||||
mocked_client.beamline_states = StateManager()
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
|
||||
)
|
||||
|
||||
widget._remove_state_requested("limits")
|
||||
|
||||
assert mocked_client.beamline_states.deleted == "limits"
|
||||
|
||||
|
||||
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._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
|
||||
assert isinstance(dialog._low_limit, BECSpinBox)
|
||||
assert isinstance(dialog._high_limit, BECSpinBox)
|
||||
assert dialog._low_limit.width() == dialog._device.width()
|
||||
|
||||
|
||||
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._device.setCurrentText("s")
|
||||
|
||||
assert dialog._name.text() == ""
|
||||
|
||||
dialog._device.set_device("samx")
|
||||
|
||||
assert dialog._name.text() == "samx_limits"
|
||||
|
||||
|
||||
def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client):
|
||||
dialog = AddBeamlineStateDialog(client=mocked_client)
|
||||
qtbot.addWidget(dialog)
|
||||
device = dialog._device
|
||||
signal = dialog._signal
|
||||
|
||||
dialog.reject()
|
||||
assert shiboken6.isValid(device)
|
||||
assert shiboken6.isValid(signal)
|
||||
|
||||
dialog.cleanup()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
assert not shiboken6.isValid(device)
|
||||
assert not shiboken6.isValid(signal)
|
||||
@@ -869,7 +869,14 @@ class TestToolbarFunctionality:
|
||||
|
||||
def test_toolbar_utils_actions(self, advanced_dock_area):
|
||||
"""Test utils toolbar actions trigger widget creation."""
|
||||
utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"]
|
||||
utils_actions = [
|
||||
"queue",
|
||||
"terminal",
|
||||
"status",
|
||||
"progress_bar",
|
||||
"sbb_monitor",
|
||||
"beamline_state_manager",
|
||||
]
|
||||
|
||||
for action_name in utils_actions:
|
||||
with patch.object(advanced_dock_area, "new") as mock_new:
|
||||
@@ -2428,6 +2435,7 @@ class TestFlatToolbarActions:
|
||||
"flat_terminal",
|
||||
"flat_bec_shell",
|
||||
"flat_sbb_monitor",
|
||||
"flat_beamline_state_manager",
|
||||
]
|
||||
|
||||
for action_name in utils_actions:
|
||||
@@ -2472,6 +2480,7 @@ class TestFlatToolbarActions:
|
||||
"flat_terminal": "BecConsole",
|
||||
"flat_bec_shell": "BECShell",
|
||||
"flat_sbb_monitor": "SBBMonitor",
|
||||
"flat_beamline_state_manager": "BeamlineStateManager",
|
||||
}
|
||||
|
||||
for action_name, widget_type in utils_action_mapping.items():
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF
|
||||
from qtpy.QtGui import QEnterEvent
|
||||
from qtpy.QtGui import QEnterEvent, QStandardItem
|
||||
from qtpy.QtWidgets import QApplication, QFrame, QLabel
|
||||
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import (
|
||||
@@ -74,6 +74,10 @@ def test_event_consumes_status_tip(bec_main_window):
|
||||
assert bec_main_window.event(status_tip_event) is True
|
||||
|
||||
|
||||
def test_event_ignores_non_qevent(bec_main_window):
|
||||
assert bec_main_window.event(QStandardItem()) is False
|
||||
|
||||
|
||||
def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window):
|
||||
with patch.object(
|
||||
QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: [])
|
||||
|
||||
Reference in New Issue
Block a user