0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

feat(widgets): added device box with spinner

This commit is contained in:
2024-07-04 18:37:50 +02:00
committed by wyzula_j
parent 903ce7d46b
commit 1b017edfad
23 changed files with 946 additions and 1 deletions

View File

@ -144,6 +144,9 @@ tests:
coverage_report:
coverage_format: cobertura
path: coverage.xml
paths:
- tests/unit_tests/reference_failures/
when: always
test-matrix:
parallel:

View File

@ -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_())

View File

@ -0,0 +1 @@
{'files': ['device_box.py']}

View File

@ -0,0 +1,179 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>251</width>
<height>289</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>192</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="device_box">
<property name="title">
<string>Device Name</string>
</property>
<layout class="QGridLayout" name="gridLayout" rowstretch="0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="step_size"/>
</item>
<item row="3" column="2">
<widget class="QToolButton" name="tweak_right">
<property name="minimumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="arrowType">
<enum>Qt::ArrowType::RightArrow</enum>
</property>
</widget>
</item>
<item row="2" column="0" colspan="3">
<widget class="QLineEdit" name="setpoint"/>
</item>
<item row="3" column="0">
<widget class="QToolButton" name="tweak_left">
<property name="minimumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>...</string>
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="arrowType">
<enum>Qt::ArrowType::LeftArrow</enum>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<widget class="QPushButton" name="stop">
<property name="text">
<string>Stop</string>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3">
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Expanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="PositionIndicator" name="position_indicator"/>
</item>
<item>
<widget class="QLabel" name="readback">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@ -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 = """
<ui language='c++'>
<widget class='DeviceBox' name='device_box'>
</widget>
</ui>
"""
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()

View File

@ -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()

View File

@ -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_()

View File

@ -0,0 +1 @@
{'files': ['position_indicator.py']}

View File

@ -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 = """
<ui language='c++'>
<widget class='PositionIndicator' name='position_indicator'>
</widget>
</ui>
"""
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()

View File

@ -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()

View File

View File

@ -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()

View File

@ -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())

View File

@ -0,0 +1 @@
{'files': ['spinner.py']}

View File

@ -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 = """
<ui language='c++'>
<widget class='SpinnerWidget' name='spinner_widget'>
</widget>
</ui>
"""
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()

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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

View File

@ -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")