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
+
+
+
+ PositionIndicator
+ QWidget
+
+
+
+
+
+
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")