1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-12 11:40:54 +02:00

Compare commits

...

14 Commits

Author SHA1 Message Date
semantic-release
111dcef35a 1.15.1
Automatically generated by python-semantic-release
2025-01-13 13:41:49 +00:00
3b04b985b6 fix(error_popups): SafeProperty wrapper extended to catch more errors and not crash Designer 2025-01-13 11:25:25 +01:00
semantic-release
5944626d93 1.15.0
Automatically generated by python-semantic-release
2025-01-10 15:51:23 +00:00
a00d368c25 feat(widget_state_manager): example app added 2025-01-10 16:32:31 +01:00
01b4608331 feat(widget_state_manager): state manager for single widget 2025-01-10 16:32:31 +01:00
semantic-release
b7221d1151 1.14.1
Automatically generated by python-semantic-release
2025-01-10 14:34:09 +00:00
fa9ecaf433 fix: cast spinner widget angle to int when using for arc 2025-01-10 15:22:58 +01:00
semantic-release
c751d25f85 1.14.0
Automatically generated by python-semantic-release
2025-01-09 14:29:40 +00:00
e2c7dc98d2 docs: add docs for games/minesweeper 2025-01-09 15:24:00 +01:00
507d46f88b feat(widget): make Minesweeper into BEC widget 2025-01-09 15:24:00 +01:00
57dc1a3afc feat(widgets): added minesweeper widget 2025-01-09 15:24:00 +01:00
semantic-release
6a78da0e71 1.13.0
Automatically generated by python-semantic-release
2025-01-09 14:18:04 +00:00
fb545eebb3 tests(safeslot): wait for panels to be properly rendered 2025-01-09 14:55:31 +01:00
b4a240e463 tests(e2e): wait for the plotting to finish before checking the data 2025-01-09 14:38:58 +01:00
20 changed files with 6685 additions and 22 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
@@ -3181,6 +3182,9 @@ class LMFitDialog(RPCBase):
"""
class Minesweeper(RPCBase): ...
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):

View File

@@ -2,25 +2,52 @@ import functools
import sys
import traceback
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, **prop_kwargs):
def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None, **prop_kwargs):
"""
Decorator to create a Qt Property with a safe setter that won't crash Designer on errors.
Behaves similarly to SafeSlot, but for properties.
Decorator to create a Qt Property with safe getter and setter so that
Qt Designer won't crash if an exception occurs in either method.
Args:
prop_type: The property type (e.g., str, bool, "QStringList", etc.)
popup_error (bool): If True, show popup on error, otherwise just handle it silently.
*prop_args, **prop_kwargs: Additional arguments and keyword arguments accepted by Property.
prop_type: The property type (e.g., str, bool, int, custom classes, etc.)
popup_error (bool): If True, show a popup for any error; otherwise, ignore or log silently.
default: Any default/fallback value to return if the getter raises an exception.
*prop_args, **prop_kwargs: Passed along to the underlying Qt Property constructor.
Usage:
@SafeProperty(int, default=-1)
def some_value(self) -> int:
# your getter logic
return ... # if an exception is raised, returns -1
@some_value.setter
def some_value(self, val: int):
# your setter logic
...
"""
def decorator(getter):
def decorator(py_getter):
@functools.wraps(py_getter)
def safe_getter(self_):
try:
return py_getter(self_)
except Exception:
if popup_error:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
# Return the user-defined default (which might be anything, including None).
else:
error_msg = traceback.format_exc()
bec_logger.error(error_msg)
return default
class PropertyWrapper:
def __init__(self, getter_func):
self.getter_func = getter_func
# We store only our safe_getter in the wrapper
self.getter_func = safe_getter
def setter(self, setter_func):
@functools.wraps(setter_func)
@@ -32,12 +59,20 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, **prop_kwargs
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=True
)
# Swallow the exception; no crash in Designer
else:
return
error_msg = traceback.format_exc()
bec_logger.error(error_msg)
return
# Return the full read/write Property
return Property(prop_type, self.getter_func, safe_setter, *prop_args, **prop_kwargs)
return PropertyWrapper(getter)
def __call__(self):
# If the user never chains a .setter(...) call, we produce a read-only property
return Property(prop_type, self.getter_func, None, *prop_args, **prop_kwargs)
return PropertyWrapper(py_getter)
return decorator

View File

@@ -224,3 +224,11 @@ DEVICES = [
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements

View File

@@ -0,0 +1,151 @@
from __future__ import annotations
from qtpy.QtCore import QSettings
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QHBoxLayout,
QLineEdit,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
class WidgetStateManager:
"""
A class to manage the state of a widget by saving and loading the state to and from a INI file.
Args:
widget(QWidget): The widget to manage the state for.
"""
def __init__(self, widget):
self.widget = widget
def save_state(self, filename: str = None):
"""
Save the state of the widget to a INI file.
Args:
filename(str): The filename to save the state to.
"""
if not filename:
filename, _ = QFileDialog.getSaveFileName(
self.widget, "Save Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._save_widget_state_qsettings(self.widget, settings)
def load_state(self, filename: str = None):
"""
Load the state of the widget from a INI file.
Args:
filename(str): The filename to load the state from.
"""
if not filename:
filename, _ = QFileDialog.getOpenFileName(
self.widget, "Load Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._load_widget_state_qsettings(self.widget, settings)
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Save the state of the widget to QSettings.
Args:
widget(QWidget): The widget to save the state for.
settings(QSettings): The QSettings object to save the state to.
"""
meta = widget.metaObject()
settings.beginGroup(widget.objectName())
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
value = widget.property(name)
settings.setValue(name, value)
settings.endGroup()
# Recursively save child widgets
for child in widget.findChildren(QWidget):
if child.objectName():
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
"""
Load the state of the widget from QSettings.
Args:
widget(QWidget): The widget to load the state for.
settings(QSettings): The QSettings object to load the state from.
"""
meta = widget.metaObject()
settings.beginGroup(widget.objectName())
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if settings.contains(name):
value = settings.value(name)
widget.setProperty(name, value)
settings.endGroup()
# Recursively load child widgets
for child in widget.findChildren(QWidget):
if child.objectName():
self._load_widget_state_qsettings(child, settings)
class ExampleApp(QWidget): # pragma: no cover
def __init__(self):
super().__init__()
self.setObjectName("MainWindow")
self.setWindowTitle("State Manager Example")
layout = QVBoxLayout(self)
# A line edit to store some user text
self.line_edit = QLineEdit(self)
self.line_edit.setObjectName("MyLineEdit")
self.line_edit.setPlaceholderText("Enter some text here...")
layout.addWidget(self.line_edit)
# A spin box to hold a numeric value
self.spin_box = QSpinBox(self)
self.spin_box.setObjectName("MySpinBox")
self.spin_box.setRange(0, 100)
layout.addWidget(self.spin_box)
# A checkbox to hold a boolean value
self.check_box = QCheckBox("Enable feature?", self)
self.check_box.setObjectName("MyCheckBox")
layout.addWidget(self.check_box)
# Buttons to save and load state
button_layout = QHBoxLayout()
self.save_button = QPushButton("Save State", self)
self.load_button = QPushButton("Load State", self)
button_layout.addWidget(self.save_button)
button_layout.addWidget(self.load_button)
layout.addLayout(button_layout)
# Create the state manager
self.state_manager = WidgetStateManager(self)
# Connect buttons
self.save_button.clicked.connect(lambda: self.state_manager.save_state())
self.load_button.clicked.connect(lambda: self.state_manager.load_state())
if __name__ == "__main__": # pragma: no cover:
import sys
app = QApplication(sys.argv)
w = ExampleApp()
w.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,3 @@
from bec_widgets.widgets.games.minesweeper import Minesweeper
__ALL__ = ["Minesweeper"]

View File

@@ -0,0 +1,413 @@
import enum
import random
import time
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
NUM_COLORS = {
1: QColor("#f44336"),
2: QColor("#9C27B0"),
3: QColor("#3F51B5"),
4: QColor("#03A9F4"),
5: QColor("#00BCD4"),
6: QColor("#4CAF50"),
7: QColor("#E91E63"),
8: QColor("#FF9800"),
}
LEVELS: dict[str, tuple[int, int]] = {"1": (8, 10), "2": (16, 40), "3": (24, 99)}
class GameStatus(enum.Enum):
READY = 0
PLAYING = 1
FAILED = 2
SUCCESS = 3
class Pos(QWidget):
expandable = Signal(int, int)
clicked = Signal()
ohno = Signal()
def __init__(self, x, y, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedSize(QSize(20, 20))
self.x = x
self.y = y
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
def reset(self):
"""Restore the tile to its original state before mine status is assigned"""
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
self.update()
def paintEvent(self, event):
p = QPainter(self)
r = event.rect()
if self.is_revealed:
color = self.palette().base().color()
outer, inner = color, color
else:
outer, inner = (self.palette().highlightedText().color(), self.palette().text().color())
p.fillRect(r, QBrush(inner))
pen = QPen(outer)
pen.setWidth(1)
p.setPen(pen)
p.drawRect(r)
if self.is_revealed:
if self.is_mine:
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
elif self.adjacent_n > 0:
pen = QPen(NUM_COLORS[self.adjacent_n])
p.setPen(pen)
f = p.font()
f.setBold(True)
p.setFont(f)
p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n))
elif self.is_flagged:
p.drawPixmap(
r,
material_icon(
"flag",
size=(50, 50),
convert_to_pixmap=True,
filled=True,
color=self.palette().base().color(),
),
)
p.end()
def flag(self):
self.is_flagged = not self.is_flagged
self.update()
self.clicked.emit()
def reveal(self):
self.is_revealed = True
self.update()
def click(self):
if not self.is_revealed:
self.reveal()
if self.adjacent_n == 0:
self.expandable.emit(self.x, self.y)
self.clicked.emit()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.RightButton and not self.is_revealed:
self.flag()
return
if event.button() == Qt.MouseButton.LeftButton:
self.click()
if self.is_mine:
self.ohno.emit()
class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
self._ui_initialised = False
self._timer_start_num_seconds = 0
self._set_level_params(LEVELS["1"])
self._init_ui()
self._init_map()
self.update_status(GameStatus.READY)
self.reset_map()
self.update_status(GameStatus.READY)
def _init_ui(self):
if self._ui_initialised:
return
self._ui_initialised = True
status_hb = QHBoxLayout()
self.mines = QLabel()
self.mines.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = self.mines.font()
f.setPointSize(24)
self.mines.setFont(f)
self.reset_button = QPushButton()
self.reset_button.setFixedSize(QSize(32, 32))
self.reset_button.setIconSize(QSize(32, 32))
self.reset_button.setFlat(True)
self.reset_button.pressed.connect(self.reset_button_pressed)
self.clock = QLabel()
self.clock.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
self.clock.setFont(f)
self._timer = QTimer()
self._timer.timeout.connect(self.update_timer)
self._timer.start(1000) # 1 second timer
self.mines.setText(f"{self.num_mines:03d}")
self.clock.setText("000")
status_hb.addWidget(self.mines)
status_hb.addWidget(self.reset_button)
status_hb.addWidget(self.clock)
level_hb = QHBoxLayout()
self.level_selector = QComboBox()
self.level_selector.addItems(list(LEVELS.keys()))
level_hb.addWidget(QLabel("Level: "))
level_hb.addWidget(self.level_selector)
self.level_selector.currentTextChanged.connect(self.change_level)
vb = QVBoxLayout()
vb.addLayout(level_hb)
vb.addLayout(status_hb)
self.grid = QGridLayout()
self.grid.setSpacing(5)
vb.addLayout(self.grid)
self.setLayout(vb)
def _init_map(self):
"""Redraw the grid of mines"""
# Remove any previous grid items and reset the grid
for i in reversed(range(self.grid.count())):
w: Pos = self.grid.itemAt(i).widget()
w.clicked.disconnect(self.on_click)
w.expandable.disconnect(self.expand_reveal)
w.ohno.disconnect(self.game_over)
w.setParent(None)
w.deleteLater()
# Add positions to the map
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = Pos(x, y)
self.grid.addWidget(w, y, x)
# Connect signal to handle expansion.
w.clicked.connect(self.on_click)
w.expandable.connect(self.expand_reveal)
w.ohno.connect(self.game_over)
def reset_map(self):
"""
Reset the map and add new mines.
"""
# Clear all mine positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reset()
# Add mines to the positions
positions = []
while len(positions) < self.num_mines:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_mine = True
positions.append((x, y))
def get_adjacency_n(x, y):
positions = self.get_surrounding(x, y)
num_mines = sum(1 if w.is_mine else 0 for w in positions)
return num_mines
# Add adjacencies to the positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.adjacent_n = get_adjacency_n(x, y)
# Place starting marker
while True:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
w = self.grid.itemAtPosition(y, x).widget()
# We don't want to start on a mine.
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_start = True
# Reveal all positions around this, if they are not mines either.
for w in self.get_surrounding(x, y):
if not w.is_mine:
w.click()
break
def get_surrounding(self, x, y):
positions = []
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
positions.append(self.grid.itemAtPosition(yi, xi).widget())
return positions
def get_num_hidden(self) -> int:
"""
Get the number of hidden positions.
"""
return sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if not self.grid.itemAtPosition(y, x).widget().is_revealed
)
def get_num_remaining_flags(self) -> int:
"""
Get the number of remaining flags.
"""
return self.num_mines - sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if self.grid.itemAtPosition(y, x).widget().is_flagged
)
def reset_button_pressed(self):
match self.status:
case GameStatus.PLAYING:
self.game_over()
case GameStatus.FAILED | GameStatus.SUCCESS:
self.reset_map()
def reveal_map(self):
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reveal()
@Slot(str)
def change_level(self, level: str):
self._set_level_params(LEVELS[level])
self._init_map()
self.reset_map()
@Slot(int, int)
def expand_reveal(self, x, y):
"""
Expand the reveal to the surrounding
Args:
x (int): The x position.
y (int): The y position.
"""
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
w = self.grid.itemAtPosition(yi, xi).widget()
if not w.is_mine:
w.click()
@Slot()
def on_click(self):
"""
Handle the click event. If the game is not started, start the game.
"""
self.update_available_flags()
if self.status != GameStatus.PLAYING:
# First click.
self.update_status(GameStatus.PLAYING)
# Start timer.
self._timer_start_num_seconds = int(time.time())
return
self.check_win()
def update_available_flags(self):
"""
Update the number of available flags.
"""
self.mines.setText(f"{self.get_num_remaining_flags():03d}")
def check_win(self):
"""
Check if the game is won.
"""
if self.get_num_hidden() == self.num_mines:
self.update_status(GameStatus.SUCCESS)
def update_status(self, status: GameStatus):
"""
Update the status of the game.
Args:
status (GameStatus): The status of the game.
"""
self.status = status
match status:
case GameStatus.READY:
icon = material_icon(icon_name="add", convert_to_pixmap=False)
case GameStatus.PLAYING:
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
case GameStatus.FAILED:
icon = material_icon(icon_name="error", convert_to_pixmap=False)
case GameStatus.SUCCESS:
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
self.reset_button.setIcon(icon)
def update_timer(self):
"""
Update the timer.
"""
if self.status == GameStatus.PLAYING:
num_seconds = int(time.time()) - self._timer_start_num_seconds
self.clock.setText(f"{num_seconds:03d}")
def game_over(self):
"""Cause the game to end early"""
self.reveal_map()
self.update_status(GameStatus.FAILED)
def _set_level_params(self, level: tuple[int, int]):
self.b_size, self.num_mines = level
if __name__ == "__main__":
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("light")
widget = Minesweeper()
widget.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['minesweeper.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 bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.games.minesweeper import Minesweeper
DOM_XML = """
<ui language='c++'>
<widget class='Minesweeper' name='minesweeper'>
</widget>
</ui>
"""
class MinesweeperPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Minesweeper(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Games"
def icon(self):
return designer_material_icon(Minesweeper.ICON_NAME)
def includeFile(self):
return "minesweeper"
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 "Minesweeper"
def toolTip(self):
return "Minesweeper"
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.games.minesweeper_plugin import MinesweeperPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MinesweeperPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -75,7 +75,7 @@ class SpinnerWidget(QWidget):
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.drawArc(adjusted_rect, int(self.angle * 16), int(angle_span))
painter.end()
def closeEvent(self, event):

View File

@@ -0,0 +1,11 @@
(user.widgets.games)=
# Game widgets
To provide some entertainment during long nights at the beamline, there are game widgets available. Well, only one, so far.
## Minesweeper
![Minesweeper](./minesweeper.png)
The classic game Minesweeper. You may select from three different levels. The game can be ended or reset by clicking on the icon in the top-centre (the robot in the screenshot).

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -270,5 +270,6 @@ signal_input/signal_input.md
position_indicator/position_indicator.md
lmfit_dialog/lmfit_dialog.md
dap_combo_box/dap_combo_box.md
games/games.md
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "1.12.0"
version = "1.15.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

View File

@@ -4,7 +4,8 @@ import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECDockArea, BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
from bec_widgets.utils import Colors
# pylint: disable=unused-argument
@@ -12,7 +13,7 @@ from bec_widgets.utils import Colors
# pylint: disable=too-many-locals
def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
# BEC client shortcuts
dock = connected_client_dock
client = bec_client_lib
@@ -88,14 +89,17 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
# Try to make a scan
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# wait for scan to finish
while not status.status == "COMPLETED":
time.sleep(0.2)
status.wait()
# plot
item = queue.scan_storage.storage[-1]
plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = len(plt_last_scan_data["samx"]["samx"].val)
plot_name = "bpm4i-bpm4i"
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
plt_data = plt.get_all_data()
assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
@@ -255,11 +259,17 @@ def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot
# get data from curves
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
plt_data = widgets[0].get_all_data()
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = len(last_scan_data["samx"]["samx"].val)
plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
@@ -277,12 +287,18 @@ def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot
plt = auto_updates.get_default_figure()
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
plt_data = widgets[0].get_all_data()
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
plot_name = f"Scan {status.scan.scan_number} - bpm4i"
num_elements_bec = len(last_scan_data["samx"]["samx"].val)
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
@@ -355,6 +371,7 @@ def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_ob
gui = connected_client_gui_obj
gui.main.add_dock("test")
qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
with pytest.raises(ValueError):
gui.main.add_dock("test")
# time.sleep(0.1)

View File

@@ -1,10 +1,10 @@
import time
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
def test_rpc_waveform1d_custom_curve(connected_client_figure):
@@ -78,7 +78,7 @@ def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
}
def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
# add 3 different curves to track
@@ -97,6 +97,11 @@ def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = len(last_scan_data["samx"]["samx"].val)
for plot_name in ["bpm4i-bpm4i", "bpm3a-bpm3a", "bpm4d-bpm4d"]:
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
# get data from curves
plt_data = plt.get_all_data()

View File

@@ -0,0 +1,50 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from qtpy.QtCore import Qt
from bec_widgets.widgets.games import Minesweeper
from bec_widgets.widgets.games.minesweeper import LEVELS, GameStatus, Pos
@pytest.fixture
def minesweeper(qtbot):
widget = Minesweeper()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_minesweeper_init(minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
def test_changing_level_updates_size_and_removes_old_grid_items(minesweeper: Minesweeper):
assert minesweeper.b_size == LEVELS["1"][0]
grid_items = [minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())]
for w in grid_items:
assert w.parent() is not None
minesweeper.change_level("2")
assert minesweeper.b_size == LEVELS["2"][0]
for w in grid_items:
assert w.parent() is None
def test_game_state_changes_to_failed_on_loss(qtbot, minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
grid_items: list[Pos] = [
minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())
]
mine = [p for p in grid_items if p.is_mine][0]
with qtbot.waitSignal(mine.ohno, timeout=1000):
qtbot.mouseRelease(mine, Qt.MouseButton.LeftButton)
assert minesweeper.status == GameStatus.FAILED
def test_game_resets_on_reset_click(minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
minesweeper.grid.itemAt(1).widget().ohno.emit()
assert minesweeper.status == GameStatus.FAILED
minesweeper.reset_button_pressed()
assert minesweeper.status == GameStatus.PLAYING

View File

@@ -19,6 +19,12 @@ def test_spinner_widget_paint_event(spinner_widget, qtbot):
spinner_widget.paintEvent(None)
def test_spinnner_with_float_angle(spinner_widget, qtbot):
spinner_widget.start()
spinner_widget.angle = 0.123453453453453
spinner_widget.paintEvent(None)
def test_spinner_widget_rendered(spinner_widget, qtbot, tmpdir):
spinner_widget.update()
qtbot.wait(200)

View File

@@ -0,0 +1,135 @@
import os
import tempfile
import pytest
from qtpy.QtCore import Property
from qtpy.QtWidgets import QLineEdit, QVBoxLayout, QWidget
from bec_widgets.utils.widget_state_manager import WidgetStateManager
class MyLineEdit(QLineEdit):
def __init__(self, parent=None):
super().__init__(parent)
# Internal attribute to hold the color string
self._customColor = ""
@Property(str)
def customColor(self):
return self._customColor
@customColor.setter
def customColor(self, color):
self._customColor = color
@pytest.fixture
def test_widget(qtbot):
w = QWidget()
w.setObjectName("MainWidget")
layout = QVBoxLayout(w)
child1 = MyLineEdit(w)
child1.setObjectName("ChildLineEdit1")
child1.setText("Hello")
child1.customColor = "red"
child2 = MyLineEdit(w)
child2.setObjectName("ChildLineEdit2")
child2.setText("World")
child2.customColor = "blue"
layout.addWidget(child1)
layout.addWidget(child2)
qtbot.addWidget(w)
qtbot.waitExposed(w)
return w
def test_save_load_widget_state(test_widget):
"""
Test saving and loading the state
"""
manager = WidgetStateManager(test_widget)
# Before saving, confirm initial properties
child1 = test_widget.findChild(MyLineEdit, "ChildLineEdit1")
child2 = test_widget.findChild(MyLineEdit, "ChildLineEdit2")
assert child1.text() == "Hello"
assert child1.customColor == "red"
assert child2.text() == "World"
assert child2.customColor == "blue"
# Create a temporary file to save settings
with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file:
tmp_filename = tmp_file.name
# Save the current state
manager.save_state(tmp_filename)
# Modify the widget properties
child1.setText("Changed1")
child1.customColor = "green"
child2.setText("Changed2")
child2.customColor = "yellow"
assert child1.text() == "Changed1"
assert child1.customColor == "green"
assert child2.text() == "Changed2"
assert child2.customColor == "yellow"
# Load the previous state
manager.load_state(tmp_filename)
# Confirm that the state has been restored
assert child1.text() == "Hello"
assert child1.customColor == "red"
assert child2.text() == "World"
assert child2.customColor == "blue"
# Clean up temporary file
os.remove(tmp_filename)
def test_save_load_without_filename(test_widget, monkeypatch, qtbot):
"""
Test that the dialog would open if filename is not provided.
"""
manager = WidgetStateManager(test_widget)
# Mock QFileDialog.getSaveFileName to return a temporary filename
with tempfile.NamedTemporaryFile(delete=False, suffix=".ini") as tmp_file:
tmp_filename = tmp_file.name
def mock_getSaveFileName(*args, **kwargs):
return tmp_filename, "INI Files (*.ini)"
def mock_getOpenFileName(*args, **kwargs):
return tmp_filename, "INI Files (*.ini)"
from qtpy.QtWidgets import QFileDialog
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_getSaveFileName)
monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_getOpenFileName)
# Initial values
child1 = test_widget.findChild(MyLineEdit, "ChildLineEdit1")
assert child1.text() == "Hello"
# Save state without providing filename -> uses dialog mock
manager.save_state()
# Change property
child1.setText("Modified")
# Load state using dialog mock
manager.load_state()
# State should be restored
assert child1.text() == "Hello"
# Clean up
os.remove(tmp_filename)