diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 60e78452..982ad6d1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -144,6 +144,9 @@ tests: coverage_report: coverage_format: cobertura path: coverage.xml + paths: + - tests/unit_tests/reference_failures/ + when: always test-matrix: parallel: diff --git a/bec_widgets/widgets/device_box/__init__.py b/bec_widgets/widgets/device_box/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/device_box/device_box.py b/bec_widgets/widgets/device_box/device_box.py new file mode 100644 index 00000000..edd758a8 --- /dev/null +++ b/bec_widgets/widgets/device_box/device_box.py @@ -0,0 +1,197 @@ +import os +import uuid + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import ScanQueueMessage +from qtpy.QtCore import Property, Signal, Slot +from qtpy.QtGui import QDoubleValidator +from qtpy.QtWidgets import QDoubleSpinBox, QVBoxLayout, QWidget + +from bec_widgets.utils import UILoader +from bec_widgets.utils.bec_connector import BECConnector + + +class DeviceBox(BECConnector, QWidget): + device_changed = Signal(str, str) + + def __init__(self, parent=None, device=None, *args, **kwargs): + super().__init__(*args, **kwargs) + QWidget.__init__(self, parent=parent) + self.get_bec_shortcuts() + self._device = "" + self._limits = None + + self.init_ui() + + if device is not None: + self.device = device + self.init_device() + + def init_ui(self): + self.device_changed.connect(self.on_device_change) + + current_path = os.path.dirname(__file__) + self.ui = UILoader(self).loader(os.path.join(current_path, "device_box.ui")) + + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.ui) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + # fix the size of the device box + db = self.ui.device_box + db.setFixedHeight(234) + db.setFixedWidth(224) + + self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType) + self.ui.stop.clicked.connect(self.on_stop) + self.ui.tweak_right.clicked.connect(self.on_tweak_right) + self.ui.tweak_right.setToolTip("Tweak right") + self.ui.tweak_left.clicked.connect(self.on_tweak_left) + self.ui.tweak_left.setToolTip("Tweak left") + self.ui.setpoint.returnPressed.connect(self.on_setpoint_change) + + self.setpoint_validator = QDoubleValidator() + self.ui.setpoint.setValidator(self.setpoint_validator) + self.ui.spinner_widget.start() + + def init_device(self): + if self.device in self.dev: + data = self.dev[self.device].read() + self.on_device_readback({"signals": data}, {}) + + @Property(str) + def device(self): + return self._device + + @device.setter + def device(self, value): + if not value or not isinstance(value, str): + return + old_device = self._device + self._device = value + self.device_changed.emit(old_device, value) + + @Slot(str, str) + def on_device_change(self, old_device: str, new_device: str): + if new_device not in self.dev: + print(f"Device {new_device} not found in the device list") + return + print(f"Device changed from {old_device} to {new_device}") + self.init_device() + self.bec_dispatcher.disconnect_slot( + self.on_device_readback, MessageEndpoints.device_readback(old_device) + ) + self.bec_dispatcher.connect_slot( + self.on_device_readback, MessageEndpoints.device_readback(new_device) + ) + self.ui.device_box.setTitle(new_device) + self.ui.readback.setToolTip(f"{self.device} readback") + self.ui.setpoint.setToolTip(f"{self.device} setpoint") + self.ui.step_size.setToolTip(f"Step size for {new_device}") + + precision = self.dev[new_device].precision + if precision is not None: + self.ui.step_size.setDecimals(precision) + self.ui.step_size.setValue(10**-precision * 10) + + @Slot(dict, dict) + def on_device_readback(self, msg_content: dict, metadata: dict): + signals = msg_content.get("signals", {}) + # pylint: disable=protected-access + hinted_signals = self.dev[self.device]._hints + precision = self.dev[self.device].precision + + readback_val = None + setpoint_val = None + + if len(hinted_signals) == 1: + signal = hinted_signals[0] + readback_val = signals.get(signal, {}).get("value") + + if f"{self.device}_setpoint" in signals: + setpoint_val = signals.get(f"{self.device}_setpoint", {}).get("value") + + if f"{self.device}_motor_is_moving" in signals: + is_moving = signals.get(f"{self.device}_motor_is_moving", {}).get("value") + if is_moving: + self.ui.spinner_widget.start() + self.ui.spinner_widget.setToolTip("Device is moving") + else: + self.ui.spinner_widget.stop() + self.ui.spinner_widget.setToolTip("Device is idle") + + if readback_val is not None: + self.ui.readback.setText(f"{readback_val:.{precision}f}") + + if setpoint_val is not None: + self.ui.setpoint.setText(f"{setpoint_val:.{precision}f}") + + limits = self.dev[self.device].limits + self.update_limits(limits) + if limits is not None and readback_val is not None and limits[0] != limits[1]: + pos = (readback_val - limits[0]) / (limits[1] - limits[0]) + self.ui.position_indicator.on_position_update(pos) + + def update_limits(self, limits): + if limits == self._limits: + return + self._limits = limits + if limits is not None and limits[0] != limits[1]: + self.ui.position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}") + self.setpoint_validator.setRange(limits[0], limits[1]) + else: + self.ui.position_indicator.setToolTip("No limits set") + self.setpoint_validator.setRange(float("-inf"), float("inf")) + + @Slot() + def on_stop(self): + request_id = str(uuid.uuid4()) + params = { + "device": self.device, + "rpc_id": request_id, + "func": "stop", + "args": [], + "kwargs": {}, + } + msg = ScanQueueMessage( + scan_type="device_rpc", + parameter=params, + queue="emergency", + metadata={"RID": request_id, "response": False}, + ) + self.client.connector.send(MessageEndpoints.scan_queue_request(), msg) + + @property + def step_size(self): + return self.ui.step_size.value() + + @Slot() + def on_tweak_right(self): + self.dev[self.device].move(self.step_size, relative=True) + + @Slot() + def on_tweak_left(self): + self.dev[self.device].move(-self.step_size, relative=True) + + @Slot() + def on_setpoint_change(self): + self.ui.setpoint.clearFocus() + setpoint = self.ui.setpoint.text() + self.dev[self.device].move(float(setpoint), relative=False) + self.ui.tweak_left.setToolTip(f"Tweak left by {self.step_size}") + self.ui.tweak_right.setToolTip(f"Tweak right by {self.step_size}") + + +if __name__ == "__main__": # pragma: no cover + import sys + + import qdarktheme + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + qdarktheme.setup_theme("light") + widget = DeviceBox(device="samx") + + widget.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/device_box/device_box.pyproject b/bec_widgets/widgets/device_box/device_box.pyproject new file mode 100644 index 00000000..b2b7ae35 --- /dev/null +++ b/bec_widgets/widgets/device_box/device_box.pyproject @@ -0,0 +1 @@ +{'files': ['device_box.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/device_box/device_box.ui b/bec_widgets/widgets/device_box/device_box.ui new file mode 100644 index 00000000..d8ac2fc4 --- /dev/null +++ b/bec_widgets/widgets/device_box/device_box.ui @@ -0,0 +1,179 @@ + + + Form + + + + 0 + 0 + 251 + 289 + + + + + 0 + 192 + + + + + 16777215 + 16777215 + + + + Form + + + + + + Device Name + + + + 0 + + + + + + + + + 50 + 50 + + + + + 50 + 50 + + + + ... + + + + 30 + 30 + + + + Qt::ArrowType::RightArrow + + + + + + + + + + + 50 + 50 + + + + + 50 + 50 + + + + ... + + + + 30 + 30 + + + + Qt::ArrowType::LeftArrow + + + + + + + Stop + + + + + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Expanding + + + + 40 + 20 + + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + + + + + + Position + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + + + SpinnerWidget + QWidget +
spinner_widget
+
+ + PositionIndicator + QWidget +
position_indicator
+
+
+ + +
diff --git a/bec_widgets/widgets/device_box/device_box_plugin.py b/bec_widgets/widgets/device_box/device_box_plugin.py new file mode 100644 index 00000000..65ce1dc5 --- /dev/null +++ b/bec_widgets/widgets/device_box/device_box_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtGui import QIcon + +from bec_widgets.widgets.device_box.device_box import DeviceBox + +DOM_XML = """ + + + + +""" + + +class DeviceBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = DeviceBox(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "Device Control" + + def icon(self): + return QIcon() + + def includeFile(self): + return "device_box" + + 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 "DeviceBox" + + def toolTip(self): + return "A widget for controlling a single positioner. " + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/device_box/register_device_box.py b/bec_widgets/widgets/device_box/register_device_box.py new file mode 100644 index 00000000..1d7aeab8 --- /dev/null +++ b/bec_widgets/widgets/device_box/register_device_box.py @@ -0,0 +1,15 @@ +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.device_box.device_box_plugin import DeviceBoxPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceBoxPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/position_indicator/position_indicator.py b/bec_widgets/widgets/position_indicator/position_indicator.py new file mode 100644 index 00000000..6a967340 --- /dev/null +++ b/bec_widgets/widgets/position_indicator/position_indicator.py @@ -0,0 +1,71 @@ +from qtpy.QtCore import Qt, Slot +from qtpy.QtGui import QPainter, QPen +from qtpy.QtWidgets import QWidget + + +class PositionIndicator(QWidget): + + def __init__(self, parent=None): + super().__init__(parent) + self.position = 0.5 + self.min_value = 0 + self.max_value = 100 + self.scaling_factor = 0.5 + self.setMinimumHeight(10) + + def set_range(self, min_value, max_value): + self.min_value = min_value + self.max_value = max_value + + @Slot(float) + def on_position_update(self, position: float): + self.position = position + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + width = self.width() + height = self.height() + + # Draw horizontal line + painter.setPen(Qt.black) + painter.drawLine(0, height // 2, width, height // 2) + + # Draw shorter vertical line at the current position + x_pos = int(self.position * width) + painter.setPen(QPen(Qt.red, 2)) + short_line_height = int(height * self.scaling_factor) + painter.drawLine( + x_pos, + (height // 2) - (short_line_height // 2), + x_pos, + (height // 2) + (short_line_height // 2), + ) + + # Draw thicker vertical lines at the ends + end_line_pen = QPen(Qt.blue, 5) + painter.setPen(end_line_pen) + painter.drawLine(0, 0, 0, height) + painter.drawLine(width - 1, 0, width - 1, height) + + +if __name__ == "__main__": + from qtpy.QtWidgets import QApplication, QSlider, QVBoxLayout + + app = QApplication([]) + + position_indicator = PositionIndicator() + slider = QSlider(Qt.Horizontal) + slider.valueChanged.connect(lambda value: position_indicator.on_position_update(value / 100)) + + layout = QVBoxLayout() + layout.addWidget(position_indicator) + layout.addWidget(slider) + + widget = QWidget() + widget.setLayout(layout) + widget.show() + + app.exec_() diff --git a/bec_widgets/widgets/position_indicator/position_indicator.pyproject b/bec_widgets/widgets/position_indicator/position_indicator.pyproject new file mode 100644 index 00000000..cb2471a3 --- /dev/null +++ b/bec_widgets/widgets/position_indicator/position_indicator.pyproject @@ -0,0 +1 @@ +{'files': ['position_indicator.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/position_indicator/position_indicator_plugin.py b/bec_widgets/widgets/position_indicator/position_indicator_plugin.py new file mode 100644 index 00000000..cbb644db --- /dev/null +++ b/bec_widgets/widgets/position_indicator/position_indicator_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtGui import QIcon + +from bec_widgets.widgets.position_indicator.position_indicator import PositionIndicator + +DOM_XML = """ + + + + +""" + + +class PositionIndicatorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = PositionIndicator(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return QIcon() + + def includeFile(self): + return "position_indicator" + + 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 "PositionIndicator" + + def toolTip(self): + return "PositionIndicator" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/position_indicator/register_position_indicator.py b/bec_widgets/widgets/position_indicator/register_position_indicator.py new file mode 100644 index 00000000..d397282f --- /dev/null +++ b/bec_widgets/widgets/position_indicator/register_position_indicator.py @@ -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.position_indicator.position_indicator_plugin import ( + PositionIndicatorPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(PositionIndicatorPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/spinner/__init__.py b/bec_widgets/widgets/spinner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/spinner/register_spinner_widget.py b/bec_widgets/widgets/spinner/register_spinner_widget.py new file mode 100644 index 00000000..b53947b9 --- /dev/null +++ b/bec_widgets/widgets/spinner/register_spinner_widget.py @@ -0,0 +1,15 @@ +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.spinner.spinner_widget_plugin import SpinnerWidgetPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(SpinnerWidgetPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/spinner/spinner.py b/bec_widgets/widgets/spinner/spinner.py new file mode 100644 index 00000000..136d4c4e --- /dev/null +++ b/bec_widgets/widgets/spinner/spinner.py @@ -0,0 +1,85 @@ +import sys + +import numpy as np +import qdarktheme +from qtpy.QtCore import QRect, Qt, QTimer +from qtpy.QtGui import QColor, QPainter, QPen +from qtpy.QtWidgets import QApplication, QMainWindow, QWidget + + +def ease_in_out_sine(t): + return 1 - np.sin(np.pi * t) + + +class SpinnerWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.angle = 0 + self.timer = QTimer(self) + self.timer.timeout.connect(self.rotate) + self.time = 0 + self.duration = 50 + self.speed = 50 + self._started = False + + def start(self): + if self._started: + return + self.timer.start(self.speed) + self._started = True + + def stop(self): + if not self._started: + return + self.timer.stop() + self._started = False + self.update() + + def rotate(self): + self.time = (self.time + 1) % self.duration + t = self.time / self.duration + easing_value = ease_in_out_sine(t) + self.angle -= (20 * easing_value) % 360 + 10 + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + size = min(self.width(), self.height()) + rect = QRect(0, 0, size, size) + + background_color = QColor(200, 200, 200, 50) + line_width = 5 + + color_palette = qdarktheme.load_palette() + + color = QColor(color_palette.accent().color()) + + rect.adjust(line_width, line_width, -line_width, -line_width) + + # Background arc + painter.setPen(QPen(background_color, line_width, Qt.SolidLine)) + adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height()) + painter.drawArc(adjusted_rect, 0, 360 * 16) + + if self._started: + # Foreground arc + pen = QPen(color, line_width, Qt.SolidLine) + pen.setCapStyle(Qt.RoundCap) + painter.setPen(pen) + proportion = 1 / 4 + angle_span = int(proportion * 360 * 16) + angle_span += angle_span * ease_in_out_sine(self.time / self.duration) + painter.drawArc(adjusted_rect, self.angle * 16, int(angle_span)) + painter.end() + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + window = QMainWindow() + widget = SpinnerWidget() + widget.start() + window.setCentralWidget(widget) + window.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/spinner/spinner_widget.pyproject b/bec_widgets/widgets/spinner/spinner_widget.pyproject new file mode 100644 index 00000000..7f39f995 --- /dev/null +++ b/bec_widgets/widgets/spinner/spinner_widget.pyproject @@ -0,0 +1 @@ +{'files': ['spinner.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/spinner/spinner_widget_plugin.py b/bec_widgets/widgets/spinner/spinner_widget_plugin.py new file mode 100644 index 00000000..7dbdd220 --- /dev/null +++ b/bec_widgets/widgets/spinner/spinner_widget_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtGui import QIcon + +from bec_widgets.widgets.spinner.spinner import SpinnerWidget + +DOM_XML = """ + + + + +""" + + +class SpinnerWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = SpinnerWidget(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return QIcon() + + def includeFile(self): + return "spinner_widget" + + 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 "SpinnerWidget" + + def toolTip(self): + return "SpinnerWidget" + + def whatsThis(self): + return self.toolTip() diff --git a/tests/unit_tests/client_mocks.py b/tests/unit_tests/client_mocks.py index e8da38a3..7679391c 100644 --- a/tests/unit_tests/client_mocks.py +++ b/tests/unit_tests/client_mocks.py @@ -49,11 +49,19 @@ class FakePositioner(FakeDevice): self.read_value = read_value self.name = name + @property + def precision(self): + return 3 + def set_read_value(self, value): self.read_value = value def read(self): - return {self.name: {"value": self.read_value}} + return { + self.name: {"value": self.read_value}, + f"{self.name}_setpoint": {"value": self.read_value}, + f"{self.name}_motor_is_moving": {"value": 0}, + } def set_limits(self, limits): self.limits = limits diff --git a/tests/unit_tests/references/SpinnerWidget_darwin.png b/tests/unit_tests/references/SpinnerWidget_darwin.png new file mode 100644 index 00000000..2b75d66a Binary files /dev/null and b/tests/unit_tests/references/SpinnerWidget_darwin.png differ diff --git a/tests/unit_tests/references/SpinnerWidget_linux.png b/tests/unit_tests/references/SpinnerWidget_linux.png new file mode 100644 index 00000000..2b75d66a Binary files /dev/null and b/tests/unit_tests/references/SpinnerWidget_linux.png differ diff --git a/tests/unit_tests/references/SpinnerWidget_started_darwin.png b/tests/unit_tests/references/SpinnerWidget_started_darwin.png new file mode 100644 index 00000000..ff6827cd Binary files /dev/null and b/tests/unit_tests/references/SpinnerWidget_started_darwin.png differ diff --git a/tests/unit_tests/references/SpinnerWidget_started_linux.png b/tests/unit_tests/references/SpinnerWidget_started_linux.png new file mode 100644 index 00000000..bf2d9470 Binary files /dev/null and b/tests/unit_tests/references/SpinnerWidget_started_linux.png differ diff --git a/tests/unit_tests/test_device_box.py b/tests/unit_tests/test_device_box.py new file mode 100644 index 00000000..0cc9f679 --- /dev/null +++ b/tests/unit_tests/test_device_box.py @@ -0,0 +1,98 @@ +from unittest import mock + +import pytest +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import ScanQueueMessage +from qtpy.QtGui import QValidator + +from bec_widgets.widgets.device_box.device_box import DeviceBox + +from .client_mocks import mocked_client + + +@pytest.fixture +def device_box(qtbot, mocked_client): + with mock.patch("bec_widgets.widgets.device_box.device_box.uuid.uuid4") as mock_uuid: + mock_uuid.return_value = "fake_uuid" + db = DeviceBox(device="samx", client=mocked_client) + qtbot.addWidget(db) + yield db + + +def test_device_box(device_box): + assert device_box.device == "samx" + data = device_box.dev["samx"].read() + + setpoint_text = device_box.ui.setpoint.text() + # check that the setpoint is taken correctly after init + assert float(setpoint_text) == data["samx_setpoint"]["value"] + + # check that the precision is taken correctly after init + precision = device_box.dev["samx"].precision + assert setpoint_text == f"{data['samx_setpoint']['value']:.{precision}f}" + + # check that the step size is set according to the device precision + assert device_box.ui.step_size.value() == 10**-precision * 10 + + +def test_device_box_update_limits(device_box): + device_box._limits = None + device_box.update_limits([0, 10]) + assert device_box._limits == [0, 10] + assert device_box.setpoint_validator.bottom() == 0 + assert device_box.setpoint_validator.top() == 10 + assert device_box.setpoint_validator.validate("100", 0) == ( + QValidator.State.Intermediate, + "100", + 0, + ) + + device_box.update_limits(None) + assert device_box._limits is None + assert device_box.setpoint_validator.validate("100", 0) == ( + QValidator.State.Acceptable, + "100", + 0, + ) + + +def test_device_box_on_stop(device_box): + with mock.patch.object(device_box.client.connector, "send") as mock_send: + device_box.on_stop() + params = {"device": "samx", "rpc_id": "fake_uuid", "func": "stop", "args": [], "kwargs": {}} + msg = ScanQueueMessage( + scan_type="device_rpc", + parameter=params, + queue="emergency", + metadata={"RID": "fake_uuid", "response": False}, + ) + mock_send.assert_called_once_with(MessageEndpoints.scan_queue_request(), msg) + + +def test_device_box_setpoint_change(device_box): + with mock.patch.object(device_box.dev["samx"], "move") as mock_move: + device_box.ui.setpoint.setText("100") + device_box.on_setpoint_change() + mock_move.assert_called_once_with(100, relative=False) + + +def test_device_box_on_tweak_right(device_box): + with mock.patch.object(device_box.dev["samx"], "move") as mock_move: + device_box.ui.step_size.setValue(0.1) + device_box.on_tweak_right() + mock_move.assert_called_once_with(0.1, relative=True) + + +def test_device_box_on_tweak_left(device_box): + with mock.patch.object(device_box.dev["samx"], "move") as mock_move: + device_box.ui.step_size.setValue(0.1) + device_box.on_tweak_left() + mock_move.assert_called_once_with(-0.1, relative=True) + + +def test_device_box_setpoint_out_of_range(device_box): + device_box.update_limits([0, 10]) + device_box.ui.setpoint.setText("100") + device_box.on_setpoint_change() + assert device_box.ui.setpoint.text() == "100" + assert device_box.ui.setpoint.hasAcceptableInput() == False diff --git a/tests/unit_tests/test_spinner.py b/tests/unit_tests/test_spinner.py new file mode 100644 index 00000000..8b6a0762 --- /dev/null +++ b/tests/unit_tests/test_spinner.py @@ -0,0 +1,92 @@ +import os +import sys + +import pytest +import qdarktheme +from PIL import Image, ImageChops +from qtpy.QtGui import QPixmap + +from bec_widgets.widgets.spinner.spinner import SpinnerWidget + + +@pytest.fixture +def spinner_widget(qtbot): + qdarktheme.setup_theme("light") + spinner = SpinnerWidget() + qtbot.addWidget(spinner) + qtbot.waitExposed(spinner) + yield spinner + + +def save_pixmap(widget, filename): + pixmap = QPixmap(widget.size()) + widget.render(pixmap) + pixmap.save(str(filename)) + return pixmap + + +def compare_images(image1_path: str, reference_image_path: str): + image1 = Image.open(image1_path) + image2 = Image.open(reference_image_path) + if image1.size != image2.size: + raise ValueError("Image size has changed") + diff = ImageChops.difference(image1, image2) + if diff.getbbox(): + # copy image1 to the reference directory to upload as artifact + output_dir = os.path.join(os.path.dirname(__file__), "reference_failures") + os.makedirs(output_dir, exist_ok=True) + image_name = os.path.join(output_dir, os.path.basename(image1_path)) + image1.save(image_name) + print(f"Image saved to {image_name}") + + raise ValueError("Images are different") + + +def test_spinner_widget_paint_event(spinner_widget, qtbot): + spinner_widget.paintEvent(None) + + +def snap_and_compare(widget, tmpdir, suffix=""): + os_suffix = sys.platform + + name = ( + f"{widget.__class__.__name__}_{suffix}_{os_suffix}.png" + if suffix + else f"{widget.__class__.__name__}_{os_suffix}.png" + ) + + # Save the widget to a pixmap + test_image_path = str(tmpdir / name) + pixmap = QPixmap(widget.size()) + widget.render(pixmap) + pixmap.save(test_image_path) + + try: + references_path = os.path.join(os.path.dirname(__file__), "references") + reference_image_path = os.path.join(references_path, name) + + if not os.path.exists(reference_image_path): + raise ValueError(f"Reference image not found: {reference_image_path}") + + compare_images(test_image_path, reference_image_path) + + except ValueError: + image = Image.open(test_image_path) + output_dir = os.path.join(os.path.dirname(__file__), "reference_failures") + os.makedirs(output_dir, exist_ok=True) + image_name = os.path.join(output_dir, name) + image.save(image_name) + print(f"Image saved to {image_name}") + raise + + +def test_spinner_widget_rendered(spinner_widget, qtbot, tmpdir): + spinner_widget.update() + qtbot.wait(200) + snap_and_compare(spinner_widget, tmpdir, suffix="") + + spinner_widget._started = True + spinner_widget.update() + qtbot.wait(200) + + snap_and_compare(spinner_widget, tmpdir, suffix="started")