Files
bec_widgets/tests/unit_tests/test_beamline_state_pill.py
T
2026-06-06 18:48:36 +02:00

634 lines
20 KiB
Python

from pathlib import Path
import shiboken6
from bec_lib import messages
from qtpy.QtCore import QCoreApplication, QEvent, QPoint, Qt
from qtpy.QtWidgets import QMessageBox, QSizePolicy
from bec_widgets.utils.colors import apply_theme
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
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_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):
apply_theme("light")
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):
apply_theme("dark")
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, monkeypatch):
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()
captured = {}
class FakeDeviceFilterDialog:
def __init__(self, devices, selected_devices, device_filter_text, parent):
captured["devices"] = devices
captured["selected_devices"] = selected_devices
captured["device_filter_text"] = device_filter_text
captured["parent"] = parent
def exec(self):
return 0
monkeypatch.setattr(pill_module, "DeviceFilterDialog", FakeDeviceFilterDialog)
widget.open_device_filter_dialog()
assert captured["devices"] == ["samx", "samy"]
assert captured["device_filter_text"] == "samx"
assert captured["parent"] is widget
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)