From 1b017edfad8e78fa079210486123976695b8915c Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 4 Jul 2024 18:37:50 +0200 Subject: [PATCH] feat(widgets): added device box with spinner --- .gitlab-ci.yml | 3 + bec_widgets/widgets/device_box/__init__.py | 0 bec_widgets/widgets/device_box/device_box.py | 197 ++++++++++++++++++ .../widgets/device_box/device_box.pyproject | 1 + bec_widgets/widgets/device_box/device_box.ui | 179 ++++++++++++++++ .../widgets/device_box/device_box_plugin.py | 54 +++++ .../widgets/device_box/register_device_box.py | 15 ++ .../position_indicator/position_indicator.py | 71 +++++++ .../position_indicator.pyproject | 1 + .../position_indicator_plugin.py | 54 +++++ .../register_position_indicator.py | 17 ++ bec_widgets/widgets/spinner/__init__.py | 0 .../spinner/register_spinner_widget.py | 15 ++ bec_widgets/widgets/spinner/spinner.py | 85 ++++++++ .../widgets/spinner/spinner_widget.pyproject | 1 + .../widgets/spinner/spinner_widget_plugin.py | 54 +++++ tests/unit_tests/client_mocks.py | 10 +- .../references/SpinnerWidget_darwin.png | Bin 0 -> 9490 bytes .../references/SpinnerWidget_linux.png | Bin 0 -> 9490 bytes .../SpinnerWidget_started_darwin.png | Bin 0 -> 14773 bytes .../SpinnerWidget_started_linux.png | Bin 0 -> 15265 bytes tests/unit_tests/test_device_box.py | 98 +++++++++ tests/unit_tests/test_spinner.py | 92 ++++++++ 23 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/widgets/device_box/__init__.py create mode 100644 bec_widgets/widgets/device_box/device_box.py create mode 100644 bec_widgets/widgets/device_box/device_box.pyproject create mode 100644 bec_widgets/widgets/device_box/device_box.ui create mode 100644 bec_widgets/widgets/device_box/device_box_plugin.py create mode 100644 bec_widgets/widgets/device_box/register_device_box.py create mode 100644 bec_widgets/widgets/position_indicator/position_indicator.py create mode 100644 bec_widgets/widgets/position_indicator/position_indicator.pyproject create mode 100644 bec_widgets/widgets/position_indicator/position_indicator_plugin.py create mode 100644 bec_widgets/widgets/position_indicator/register_position_indicator.py create mode 100644 bec_widgets/widgets/spinner/__init__.py create mode 100644 bec_widgets/widgets/spinner/register_spinner_widget.py create mode 100644 bec_widgets/widgets/spinner/spinner.py create mode 100644 bec_widgets/widgets/spinner/spinner_widget.pyproject create mode 100644 bec_widgets/widgets/spinner/spinner_widget_plugin.py create mode 100644 tests/unit_tests/references/SpinnerWidget_darwin.png create mode 100644 tests/unit_tests/references/SpinnerWidget_linux.png create mode 100644 tests/unit_tests/references/SpinnerWidget_started_darwin.png create mode 100644 tests/unit_tests/references/SpinnerWidget_started_linux.png create mode 100644 tests/unit_tests/test_device_box.py create mode 100644 tests/unit_tests/test_spinner.py 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 0000000000000000000000000000000000000000..2b75d66a8c62c33426eb1dab52a41e56486d9d5f GIT binary patch literal 9490 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 literal 0 HcmV?d00001 diff --git a/tests/unit_tests/references/SpinnerWidget_linux.png b/tests/unit_tests/references/SpinnerWidget_linux.png new file mode 100644 index 0000000000000000000000000000000000000000..2b75d66a8c62c33426eb1dab52a41e56486d9d5f GIT binary patch literal 9490 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ff6827cd81d84d075748822840b020dcb8d80c20 GIT binary patch literal 14773 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 literal 0 HcmV?d00001 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")