feat(beamline_state_manager): new widget

This commit is contained in:
2026-05-29 15:01:22 +02:00
parent af125e2222
commit 5de5b939e5
16 changed files with 2747 additions and 2 deletions
+106
View File
@@ -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."""
+10
View File
@@ -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)
+10 -1
View File
@@ -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():
+5 -1
View File
@@ -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: [])