1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-18 14:25:37 +02:00

Compare commits

..

5 Commits

72 changed files with 1243 additions and 511 deletions

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget

View File

@@ -15,6 +15,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.status_bar import StatusToolBar
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.waveform import Waveform
@@ -30,6 +31,7 @@ class ViewBase(QWidget):
parent (QWidget | None): Parent widget.
id (str | None): Optional view id, useful for debugging or introspection.
title (str | None): Optional human-readable title.
show_status (bool): Whether to show a status toolbar at the top of the view.
"""
def __init__(
@@ -39,6 +41,8 @@ class ViewBase(QWidget):
*,
id: str | None = None,
title: str | None = None,
show_status: bool = False,
status_names: list[str] | None = None,
):
super().__init__(parent=parent)
self.content: QWidget | None = None
@@ -49,15 +53,48 @@ class ViewBase(QWidget):
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
self.status_bar: StatusToolBar | None = None
if show_status:
# If explicit status names are provided, default to showing only those.
show_all = status_names is None
self.setup_status_bar(show_all_status=show_all, status_names=status_names)
if content is not None:
self.set_content(content)
def set_content(self, content: QWidget) -> None:
"""Replace the current content widget with a new one."""
if self.content is not None:
self.layout().removeWidget(self.content)
self.content.setParent(None)
self.content.close()
self.content.deleteLater()
self.content = content
self.layout().addWidget(content)
if self.status_bar is not None:
insert_at = self.layout().indexOf(self.status_bar) + 1
self.layout().insertWidget(insert_at, content)
else:
self.layout().addWidget(content)
def setup_status_bar(
self, *, show_all_status: bool = True, status_names: list[str] | None = None
) -> None:
"""Create and attach a status toolbar managed by the status broker."""
if self.status_bar is not None:
return
names_arg = None if show_all_status else status_names
self.status_bar = StatusToolBar(parent=self, names=names_arg)
self.layout().addWidget(self.status_bar)
def set_status(
self, name: str = "main", *, state=None, text: str | None = None, tooltip: str | None = None
) -> None:
"""Manually set a status item on the status bar."""
if self.status_bar is None:
self.setup_status_bar(show_all_status=True)
if self.status_bar is None:
return
self.status_bar.set_status(name=name, state=state, text=text, tooltip=tooltip)
@SafeSlot()
def on_enter(self) -> None:

View File

@@ -1,19 +1,10 @@
# pylint: skip-file
import json
import time
from unittest.mock import MagicMock
import h5py
from bec_lib import messages
from bec_lib.bec_service import messages
from bec_lib.config_helper import ConfigHelper
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
from bec_lib.devicemanager import DeviceContainer
from bec_lib.messages import _StoredDataInfo
from bec_lib.scan_history import ScanHistory
from qtpy.QtCore import QEvent, QEventLoop
class FakeDevice(BECDevice):
@@ -228,9 +219,7 @@ class Device(FakeDevice):
class DMMock:
def __init__(self, *args, **kwargs):
self._service = args[0]
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
def __init__(self):
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
@@ -284,10 +273,6 @@ class DMMock:
configs.append(device._config)
return configs
def initialize(*_): ...
def shutdown(self): ...
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
@@ -316,157 +301,3 @@ def check_remote_data_size(widget, plot_name, num_elements):
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
class DummyData:
def __init__(self, val, timestamps):
self.val = val
self.timestamps = timestamps
def get(self, key, default=None):
if key == "val":
return self.val
return default
def create_dummy_scan_item():
"""
Helper to create a dummy scan item with both live_data and metadata/status_message info.
"""
dummy_live_data = {
"samx": {"samx": DummyData(val=[10, 20, 30], timestamps=[100, 200, 300])},
"samy": {"samy": DummyData(val=[5, 10, 15], timestamps=[100, 200, 300])},
"bpm4i": {"bpm4i": DummyData(val=[5, 6, 7], timestamps=[101, 201, 301])},
"async_device": {"async_device": DummyData(val=[1, 2, 3], timestamps=[11, 21, 31])},
}
dummy_scan = MagicMock()
dummy_scan.live_data = dummy_live_data
dummy_scan.metadata = {
"bec": {
"scan_id": "dummy",
"scan_report_devices": ["samx"],
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
}
}
dummy_scan.status_message.info = {
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
"scan_report_devices": ["samx"],
}
return dummy_scan
def inject_scan_history(widget, scan_history_factory, *history_args):
"""
Helper to inject scan history messages into client history.
"""
history_msgs = []
for scan_id, scan_number in history_args:
history_msgs.append(scan_history_factory(scan_id=scan_id, scan_number=scan_number))
widget.client.history = ScanHistory(widget.client, False)
for msg in history_msgs:
widget.client.history._scan_data[msg.scan_id] = msg
widget.client.history._scan_ids.append(msg.scan_id)
widget.client.queue.scan_storage.current_scan = None
return history_msgs
def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanHistoryMessage:
"""
Helper to create a history file with the given data.
The data should contain readout groups, e.g.
{
"baseline": {"samx": {"samx": {"value": [1, 2, 3], "timestamp": [100, 200, 300]}},
"monitored": {"bpm4i": {"bpm4i": {"value": [5, 6, 7], "timestamp": [101, 201, 301]}}},
"async": {"async_device": {"async_device": {"value": [1, 2, 3], "timestamp": [11, 21, 31]}}},
}
"""
with h5py.File(file_path, "w") as f:
_metadata = f.create_group("entry/collection/metadata")
_metadata.create_dataset("sample_name", data="test_sample")
metadata_bec = f.create_group("entry/collection/metadata/bec")
for key, value in metadata.items():
if isinstance(value, dict):
metadata_bec.create_group(key)
for sub_key, sub_value in value.items():
if isinstance(sub_value, list):
sub_value = json.dumps(sub_value)
metadata_bec[key].create_dataset(sub_key, data=sub_value)
elif isinstance(sub_value, dict):
for sub_sub_key, sub_sub_value in sub_value.items():
sub_sub_group = metadata_bec[key].create_group(sub_key)
# Handle _StoredDataInfo objects
if isinstance(sub_sub_value, _StoredDataInfo):
# Store the numeric shape
sub_sub_group.create_dataset("shape", data=sub_sub_value.shape)
# Store the dtype as a UTF-8 string
dt = sub_sub_value.dtype or ""
sub_sub_group.create_dataset(
"dtype", data=dt, dtype=h5py.string_dtype(encoding="utf-8")
)
continue
if isinstance(sub_sub_value, list):
json_val = json.dumps(sub_sub_value)
sub_sub_group.create_dataset(sub_sub_key, data=json_val)
elif isinstance(sub_sub_value, dict):
for k2, v2 in sub_sub_value.items():
val = json.dumps(v2) if isinstance(v2, list) else v2
sub_sub_group.create_dataset(k2, data=val)
else:
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
else:
metadata_bec[key].create_dataset(sub_key, data=sub_value)
else:
metadata_bec.create_dataset(key, data=value)
for group, devices in data.items():
readout_group = f.create_group(f"entry/collection/readout_groups/{group}")
for device, device_data in devices.items():
dev_group = f.create_group(f"entry/collection/devices/{device}")
for signal, signal_data in device_data.items():
signal_group = dev_group.create_group(signal)
for signal_key, signal_values in signal_data.items():
signal_group.create_dataset(signal_key, data=signal_values)
readout_group[device] = h5py.SoftLink(f"/entry/collection/devices/{device}")
msg = messages.ScanHistoryMessage(
scan_id=metadata["scan_id"],
scan_name=metadata["scan_name"],
exit_status=metadata["exit_status"],
file_path=file_path,
scan_number=metadata["scan_number"],
dataset_number=metadata["dataset_number"],
start_time=time.time(),
end_time=time.time(),
num_points=metadata["num_points"],
request_inputs=metadata["request_inputs"],
stored_data_info=metadata.get("stored_data_info"),
metadata={"scan_report_devices": metadata.get("scan_report_devices")},
)
return msg
def create_widget(qtbot, widget, *args, **kwargs):
"""
Create a widget and add it to the qtbot for testing. This is a helper function that
should be used in all tests that require a widget to be created.
Args:
qtbot (fixture): pytest-qt fixture
widget (QWidget): widget class to be created
*args: positional arguments for the widget
**kwargs: keyword arguments for the widget
Returns:
QWidget: the created widget
"""
widget = widget(*args, **kwargs)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def process_all_deferred_deletes(qapp):
qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete)
qapp.processEvents(QEventLoop.ProcessEventsFlag.AllEvents)

View File

@@ -123,16 +123,17 @@ class BECDispatcher:
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if client is None:
if config is not None and not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
if self.client is None:
if config is not None:
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClient(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
else:
self.client = client
if self.client.started:
# have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector")

View File

@@ -5,7 +5,8 @@ import os
import weakref
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import Dict, Literal
from enum import Enum
from typing import Dict, Literal, Union
from bec_lib.device import ReadoutPriority
from bec_lib.logger import bec_logger
@@ -15,6 +16,7 @@ from qtpy.QtGui import QAction, QColor, QIcon # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QMenu,
@@ -26,6 +28,7 @@ from qtpy.QtWidgets import (
)
import bec_widgets
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
@@ -102,6 +105,205 @@ class LongPressToolButton(QToolButton):
self.showMenu()
class StatusState(str, Enum):
DEFAULT = "default"
HIGHLIGHT = "highlight"
WARNING = "warning"
EMERGENCY = "emergency"
SUCCESS = "success"
class StatusIndicatorWidget(QWidget):
"""Pill-shaped status indicator with icon + label using accent colors."""
def __init__(
self, parent=None, text: str = "Ready", state: StatusState | str = StatusState.DEFAULT
):
super().__init__(parent)
self.setObjectName("StatusIndicatorWidget")
self._text = text
self._state = self._normalize_state(state)
self._theme_connected = False
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 2, 8, 2)
layout.setSpacing(6)
self._icon_label = QLabel(self)
self._icon_label.setFixedSize(18, 18)
self._text_label = QLabel(self)
self._text_label.setText(self._text)
layout.addWidget(self._icon_label)
layout.addWidget(self._text_label)
# Give it a consistent pill height
self.setMinimumHeight(24)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
# Soft shadow similar to notification banners
self._shadow = QGraphicsDropShadowEffect(self)
self._shadow.setBlurRadius(18)
self._shadow.setOffset(0, 2)
self.setGraphicsEffect(self._shadow)
self._apply_state(self._state)
self._connect_theme_change()
def set_state(self, state: Union[StatusState, str]):
"""Update state and refresh visuals."""
self._state = self._normalize_state(state)
self._apply_state(self._state)
def set_text(self, text: str):
"""Update the displayed text."""
self._text = text
self._text_label.setText(text)
def _apply_state(self, state: StatusState):
palette = self._resolve_accent_colors()
color_attr = {
StatusState.DEFAULT: "default",
StatusState.HIGHLIGHT: "highlight",
StatusState.WARNING: "warning",
StatusState.EMERGENCY: "emergency",
StatusState.SUCCESS: "success",
}.get(state, "default")
base_color = getattr(palette, color_attr, None) or getattr(
palette, "default", QColor("gray")
)
# Apply style first (returns text color for label)
text_color = self._update_style(base_color, self._theme_fg_color())
theme_name = self._theme_name()
# Choose icon per state
icon_name_map = {
StatusState.DEFAULT: "check_circle",
StatusState.HIGHLIGHT: "check_circle",
StatusState.SUCCESS: "check_circle",
StatusState.WARNING: "warning",
StatusState.EMERGENCY: "dangerous",
}
icon_name = icon_name_map.get(state, "check_circle")
# Icon color:
# - Dark mode: follow text color (usually white) for high contrast.
# - Light mode: use a stronger version of the accent color for a colored glyph
# that stands out on the pastel pill background.
if theme_name == "light":
icon_q = QColor(base_color)
icon_color = icon_q.name(QColor.HexRgb)
else:
icon_color = text_color
icon = material_icon(
icon_name, size=(18, 18), convert_to_pixmap=False, filled=True, color=icon_color
)
if not icon.isNull():
self._icon_label.setPixmap(icon.pixmap(18, 18))
def _update_style(self, color: QColor, fg_color: QColor) -> str:
# Ensure the widget actually paints its own background
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
fg = QColor(fg_color)
text_color = fg.name(QColor.HexRgb)
theme_name = self._theme_name()
base = QColor(color)
start = QColor(base)
end = QColor(base)
border = QColor(base)
if theme_name == "light":
start.setAlphaF(0.20)
end.setAlphaF(0.06)
else:
start.setAlphaF(0.35)
end.setAlphaF(0.12)
border = border.darker(120)
# shadow color tuned per theme to match notification banners
if hasattr(self, "_shadow"):
if theme_name == "light":
shadow_color = QColor(15, 23, 42, 60) # softer shadow on light bg
else:
shadow_color = QColor(0, 0, 0, 160)
self._shadow.setColor(shadow_color)
# Use a fixed radius for a stable pill look inside toolbars
radius = 10
self.setStyleSheet(
f"""
#StatusIndicatorWidget {{
background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:1,
stop:0 {start.name(QColor.HexArgb)}, stop:1 {end.name(QColor.HexArgb)});
border: 1px solid {border.name(QColor.HexRgb)};
border-radius: {radius}px;
padding: 2px 8px;
}}
#StatusIndicatorWidget QLabel {{
color: {text_color};
background: transparent;
}}
"""
)
return text_color
def _theme_fg_color(self) -> QColor:
app = QApplication.instance()
theme = getattr(app, "theme", None)
if theme is not None and hasattr(theme, "color"):
try:
fg = theme.color("FG")
if isinstance(fg, QColor):
return fg
except Exception:
pass
palette = self._resolve_accent_colors()
base = getattr(palette, "default", QColor("white"))
luminance = (0.299 * base.red() + 0.587 * base.green() + 0.114 * base.blue()) / 255
return QColor("#000000") if luminance > 0.65 else QColor("#ffffff")
def _theme_name(self) -> str:
app = QApplication.instance()
theme = getattr(app, "theme", None)
name = getattr(theme, "theme", None)
if isinstance(name, str):
return name.lower()
return "dark"
def _connect_theme_change(self):
if self._theme_connected:
return
app = QApplication.instance()
theme = getattr(app, "theme", None)
if theme is not None and hasattr(theme, "theme_changed"):
try:
theme.theme_changed.connect(lambda _: self._apply_state(self._state))
self._theme_connected = True
except Exception:
pass
@staticmethod
def _normalize_state(state: Union[StatusState, str]) -> StatusState:
if isinstance(state, StatusState):
return state
try:
return StatusState(state)
except ValueError:
return StatusState.DEFAULT
@staticmethod
def _resolve_accent_colors() -> AccentColors:
return get_accent_colors()
class ToolBarAction(ABC):
"""
Abstract base class for toolbar actions.
@@ -148,6 +350,54 @@ class SeparatorAction(ToolBarAction):
toolbar.addSeparator()
class StatusIndicatorAction(ToolBarAction):
"""Toolbar action hosting a LED indicator and status text."""
def __init__(
self,
*,
text: str = "Ready",
state: Union[StatusState, str] = StatusState.DEFAULT,
tooltip: str | None = None,
):
super().__init__(icon_path=None, tooltip=tooltip or "View status", checkable=False)
self._text = text
self._state: StatusState = StatusIndicatorWidget._normalize_state(state)
self.widget: StatusIndicatorWidget | None = None
self.tooltip = tooltip or ""
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
if (
self.widget is None
or self.widget.parent() is None
or self.widget.parent() is not toolbar
):
self.widget = StatusIndicatorWidget(parent=toolbar, text=self._text, state=self._state)
self.action = toolbar.addWidget(self.widget)
self.action.setText(self._text)
self.set_tooltip(self.tooltip)
def set_state(self, state: Union[StatusState, str]):
self._state = StatusIndicatorWidget._normalize_state(state)
if self.widget is not None:
self.widget.set_state(self._state)
def set_text(self, text: str):
self._text = text
if self.widget is not None:
self.widget.set_text(text)
if hasattr(self, "action") and self.action is not None:
self.action.setText(text)
def set_tooltip(self, tooltip: str | None):
"""Set tooltip on both the underlying widget and the QWidgetAction."""
self.tooltip = tooltip or ""
if self.widget is not None:
self.widget.setToolTip(self.tooltip)
if hasattr(self, "action") and self.action is not None:
self.action.setToolTip(self.tooltip)
class QtIconAction(IconAction):
def __init__(
self,

View File

@@ -0,0 +1,283 @@
from __future__ import annotations
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import BeamlineStateConfig
from qtpy.QtCore import QObject, QTimer, Signal
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import StatusIndicatorAction, StatusState
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
logger = bec_logger.logger
class BECStatusBroker(BECConnector, QObject):
"""Listen to BEC beamline state endpoints and emit structured signals."""
_instance: "BECStatusBroker | None" = None
_initialized: bool = False
available_updated = Signal(list) # list of states available
status_updated = Signal(str, dict) # name, status update
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, parent=None, gui_id: str | None = None, client=None, **kwargs):
if self._initialized:
return
super().__init__(parent=parent, gui_id=gui_id, client=client, **kwargs)
self._watched: set[str] = set()
self.bec_dispatcher.connect_slot(
self.on_available, MessageEndpoints.available_beamline_states()
)
self._initialized = True
self.refresh_available()
def refresh_available(self):
"""Fetch the current set of beamline conditions once."""
try:
msg = self.client.connector.get_last(MessageEndpoints.available_beamline_states())
logger.info(f"StatusBroker: fetched available conditions payload: {msg}")
if msg:
self.on_available(msg.get("data").content, None)
except Exception as exc: # pragma: no cover - runtime env
logger.debug(f"Could not fetch available conditions: {exc}")
@SafeSlot(dict, dict)
def on_available(self, data: dict, meta: dict | None = None):
state_list = data.get("states") # latest one from the stream
self.available_updated.emit(state_list)
for state in state_list:
name = state.name
if name:
self.watch_state(name)
def watch_state(self, name: str):
"""Subscribe to updates for a single beamline state."""
if name in self._watched:
return
self._watched.add(name)
endpoint = MessageEndpoints.beamline_state(name)
logger.info(f"StatusBroker: watching state '{name}' on {endpoint.endpoint}")
self.bec_dispatcher.connect_slot(self.on_state, endpoint)
self.fetch_state(name)
def fetch_state(self, name: str):
"""Fetch the current value of a beamline state once."""
endpoint = MessageEndpoints.beamline_state(name)
try:
msg = self.client.connector.get_last(endpoint)
logger.info(f"StatusBroker: fetched state '{name}' payload: {msg}")
if msg:
self.on_state(msg.get("data").content, None)
except Exception as exc: # pragma: no cover - runtime env
logger.debug(f"Could not fetch state {name}: {exc}")
@SafeSlot(dict, dict)
def on_state(self, data: dict, meta: dict | None = None):
name = data.get("name")
if not name:
return
logger.info(f"StatusBroker: state update for '{name}' -> {data}")
self.status_updated.emit(str(name), data)
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance of the BECStatusBroker.
"""
cls._instance = None
cls._initialized = False
class StatusToolBar(ModularToolBar):
"""Status toolbar that auto-manages beamline state indicators."""
STATUS_MAP: dict[str, StatusState] = {
"valid": StatusState.SUCCESS,
"warning": StatusState.WARNING,
"invalid": StatusState.EMERGENCY,
}
def __init__(self, parent=None, names: list[str] | None = None, **kwargs):
super().__init__(parent=parent, orientation="horizontal", **kwargs)
self.setObjectName("StatusToolbar")
self._status_bundle = self.new_bundle("status")
self.show_bundles(["status"])
self._apply_status_toolbar_style()
self.allowed_names: set[str] | None = set(names) if names is not None else None
logger.info(f"StatusToolbar init allowed_names={self.allowed_names}")
self.broker = BECStatusBroker()
self.broker.available_updated.connect(self.on_available_updated)
self.broker.status_updated.connect(self.on_status_updated)
QTimer.singleShot(0, self.refresh_from_broker)
def refresh_from_broker(self) -> None:
if self.allowed_names is None:
self.broker.refresh_available()
else:
for name in self.allowed_names:
if not self.components.exists(name):
# Pre-create a placeholder pill so it is visible even before data arrives.
self.add_status_item(
name=name, text=name, state=StatusState.DEFAULT, tooltip=None
)
self.broker.watch_state(name)
def _apply_status_toolbar_style(self) -> None:
self.setStyleSheet(
"QToolBar#StatusToolbar {"
f" background-color: {self.background_color};"
" border: none;"
" border-bottom: 1px solid palette(mid);"
"}"
)
# -------- Slots for updates --------
@SafeSlot(list)
def on_available_updated(self, available_states: list):
"""Process the available states stream and start watching them."""
# Keep track of current names from the broker to remove stale ones.
current_names: set[str] = set()
for state in available_states:
if not isinstance(state, BeamlineStateConfig):
continue
name = state.name
title = state.title or name
if not name:
continue
current_names.add(name)
logger.info(f"StatusToolbar: discovered state '{name}' title='{title}'")
# auto-add unless filtered out
if self.allowed_names is None or name in self.allowed_names:
self.add_status_item(name=name, text=title, state=StatusState.DEFAULT, tooltip=None)
else:
# keep hidden but present for context menu toggling
self.add_status_item(name=name, text=title, state=StatusState.DEFAULT, tooltip=None)
act = self.components.get_action(name)
if act and act.action:
act.action.setVisible(False)
# Remove actions that are no longer present in available_states.
known_actions = [
n for n in self.components._components.keys() if n not in ("separator",)
] # direct access used for clean-up
for name in known_actions:
if name not in current_names:
logger.info(f"StatusToolbar: removing stale state '{name}'")
try:
self.components.remove_action(name)
except Exception as exc:
logger.warning(f"Failed to remove stale state '{name}': {exc}")
self.refresh()
@SafeSlot(str, dict)
def on_status_updated(self, name: str, payload: dict): # TODO finish update logic
"""Update a status pill when a state update arrives."""
state = self.STATUS_MAP.get(str(payload.get("status", "")).lower(), StatusState.DEFAULT)
action = self.components.get_action(name) if self.components.exists(name) else None
# Only update the label when a title is explicitly provided; otherwise keep current text.
title = payload.get("title") or None
text = title
if text is None and action is None:
text = payload.get("name") or name
if "label" in payload:
tooltip = payload.get("label") or ""
else:
tooltip = None
logger.info(
f"StatusToolbar: update state '{name}' -> state={state} text='{text}' tooltip='{tooltip}'"
)
self.set_status(name=name, text=text, state=state, tooltip=tooltip)
# -------- Items Management --------
def add_status_item(
self,
name: str,
*,
text: str = "Ready",
state: StatusState | str = StatusState.DEFAULT,
tooltip: str | None = None,
) -> StatusIndicatorAction | None:
"""
Add or update a named status item in the toolbar.
After you added all actions, call `toolbar.refresh()` to update the display.
Args:
name(str): Unique name for the status item.
text(str): Text to display in the status item.
state(StatusState | str): State of the status item.
tooltip(str | None): Optional tooltip for the status item.
Returns:
StatusIndicatorAction | None: The created or updated status action, or None if toolbar is not initialized.
"""
if self._status_bundle is None:
return
if self.components.exists(name):
return
action = StatusIndicatorAction(text=text, state=state, tooltip=tooltip)
return self.add_status_action(name, action)
def add_status_action(
self, name: str, action: StatusIndicatorAction
) -> StatusIndicatorAction | None:
"""
Attach an existing StatusIndicatorAction to the status toolbar.
After you added all actions, call `toolbar.refresh()` to update the display.
Args:
name(str): Unique name for the status item.
action(StatusIndicatorAction): The status action to add.
Returns:
StatusIndicatorAction | None: The added status action, or None if toolbar is not initialized.
"""
self.components.add_safe(name, action)
self.get_bundle("status").add_action(name)
self.refresh()
self.broker.fetch_state(name)
return action
def set_status(
self,
name: str = "main",
*,
state: StatusState | str | None = None,
text: str | None = None,
tooltip: str | None = None,
) -> None:
"""
Update the status item with the given name, creating it if necessary.
Args:
name(str): Unique name for the status item.
state(StatusState | str | None): New state for the status item.
text(str | None): New text for the status item.
"""
action = self.components.get_action(name) if self.components.exists(name) else None
if action is None:
action = self.add_status_item(
name, text=text or "Ready", state=state or "default", tooltip=tooltip
)
if action is None:
return
if state is not None:
action.set_state(state)
if text is not None:
action.set_text(text)
if tooltip is not None and hasattr(action, "set_tooltip"):
action.set_tooltip(tooltip)

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
@@ -22,6 +21,7 @@ from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.status_bar import StatusToolBar
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker,
@@ -115,14 +115,11 @@ class BECMainWindow(BECWidget, QMainWindow):
Prepare the BEC specific widgets in the status bar.
"""
# Left: AppID label
self._app_id_label = QLabel()
self._app_id_label.setAlignment(
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
)
self.status_bar.addWidget(self._app_id_label)
# Left: Beamline condition status toolbar (auto-fetches all conditions)
self._status_toolbar = StatusToolBar(parent=self, names=None)
self.status_bar.addWidget(self._status_toolbar)
# Add a separator after the app ID label
# Add a separator after the status toolbar
self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands)
@@ -341,13 +338,27 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
help_menu.addSeparator()
self._app_id_action = QAction(self)
self._app_id_action.triggered.connect(self._copy_app_id_to_clipboard)
help_menu.addAction(self._app_id_action)
def _copy_app_id_to_clipboard(self):
"""
Copy the app ID to the clipboard.
"""
if self.bec_dispatcher.cli_server is not None:
server_id = self.bec_dispatcher.cli_server.gui_id
clipboard = QApplication.clipboard()
clipboard.setText(server_id)
################################################################################
# Status Bar Addons
################################################################################
def display_app_id(self):
"""
Display the app ID in the status bar.
Display the app ID in the Help menu.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
@@ -355,7 +366,8 @@ class BECMainWindow(BECWidget, QMainWindow):
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self._app_id_label.setText(status_message)
if hasattr(self, "_app_id_action"):
self._app_id_action.setText(status_message)
@SafeSlot(dict, dict)
def display_client_message(self, msg: dict, meta: dict):

View File

@@ -74,15 +74,15 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
"""This test checks that all widgets that are available via gui.available_widgets can be created and removed."""
gui = connected_client_gui_obj
dock_area = gui.bec
# Number of top level widgets, should be 4
top_level_widgets_count = 12
# Number of top level widgets, should be 5
top_level_widgets_count = 13
assert len(gui._server_registry) == top_level_widgets_count
names = set(list(gui._server_registry.keys()))
# Number of widgets with parent_id == None, should be 2
# Number of widgets with parent_id == None, should be 3
widgets = [
widget for widget in gui._server_registry.values() if widget["config"]["parent_id"] is None
]
assert len(widgets) == 2
assert len(widgets) == 3
# Test all relevant widgets
for object_name in gui.available_widgets.__dict__:
@@ -115,7 +115,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
for widget in gui._server_registry.values()
if widget["config"]["parent_id"] is None
]
assert len(widgets) == 2
assert len(widgets) == 3
#############################
####### Remove widget #######

View File

@@ -0,0 +1,256 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from math import inf
from unittest.mock import MagicMock, patch
import fakeredis
import pytest
from bec_lib.bec_service import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from bec_lib.scan_history import ScanHistory
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
def fake_redis_server(host, port, **kwargs):
redis = fakeredis.FakeRedis()
return redis
@pytest.fixture(scope="function")
def mocked_client(bec_dispatcher):
connector = RedisConnector("localhost:1", redis_cls=fake_redis_server)
# Create a MagicMock object
client = MagicMock() # TODO change to real BECClient
# Shutdown the original client
bec_dispatcher.client.shutdown()
# Mock the connector attribute
bec_dispatcher.client = client
# Mock the device_manager.devices attribute
client.connector = connector
client.device_manager = DMMock()
client.device_manager.add_devices(DEVICES)
def mock_mv(*args, relative=False):
# Extracting motor and value pairs
for i in range(0, len(args), 2):
motor = args[i]
value = args[i + 1]
motor.move(value, relative=relative)
return MagicMock(wait=MagicMock())
client.scans = MagicMock(mv=mock_mv)
# Ensure isinstance check for Positioner passes
original_isinstance = isinstance
def isinstance_mock(obj, class_info):
if class_info == Positioner and isinstance(obj, FakePositioner):
return True
return original_isinstance(obj, class_info)
with patch("builtins.isinstance", new=isinstance_mock):
yield client
connector.shutdown() # TODO change to real BECClient
##################################################
# Client Fixture with DAP
##################################################
@pytest.fixture(scope="function")
def dap_plugin_message():
msg = messages.AvailableResourceMessage(
**{
"resource": {
"GaussianModel": {
"class": "LmfitService1D",
"user_friendly_name": "GaussianModel",
"class_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n ",
"run_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n \n Args:\n scan_item (ScanItem): Scan item or scan ID\n device_x (DeviceBase | str): Device name for x\n signal_x (DeviceBase | str): Signal name for x\n device_y (DeviceBase | str): Device name for y\n signal_y (DeviceBase | str): Signal name for y\n parameters (dict): Fit parameters\n ",
"run_name": "fit",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "scan_item",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "ScanItem | str",
},
{
"name": "device_x",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "signal_x",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "device_y",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "signal_y",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "parameters",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
"auto_fit_supported": True,
"params": {
"amplitude": {
"name": "amplitude",
"value": 1.0,
"vary": True,
"min": -inf,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"center": {
"name": "center",
"value": 0.0,
"vary": True,
"min": -inf,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"sigma": {
"name": "sigma",
"value": 1.0,
"vary": True,
"min": 0,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"fwhm": {
"name": "fwhm",
"value": 2.35482,
"vary": False,
"min": -inf,
"max": inf,
"expr": "2.3548200*sigma",
"brute_step": None,
"user_data": None,
},
"height": {
"name": "height",
"value": 0.3989423,
"vary": False,
"min": -inf,
"max": inf,
"expr": "0.3989423*amplitude/max(1e-15, sigma)",
"brute_step": None,
"user_data": None,
},
},
"class_args": [],
"class_kwargs": {"model": "GaussianModel"},
}
}
}
)
yield msg
@pytest.fixture(scope="function")
def mocked_client_with_dap(mocked_client, dap_plugin_message):
dap_services = {
"BECClient": messages.StatusMessage(name="BECClient", status=1, info={}),
"DAPServer/LmfitService1D": messages.StatusMessage(
name="LmfitService1D", status=1, info={}
),
}
client = mocked_client
client.service_status = dap_services
client.connector.set(
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
)
# Patch the client's DAP attribute so that the available models include "GaussianModel"
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
client.dap._available_dap_plugins = patched_models
yield client
class DummyData:
def __init__(self, val, timestamps):
self.val = val
self.timestamps = timestamps
def get(self, key, default=None):
if key == "val":
return self.val
return default
def create_dummy_scan_item():
"""
Helper to create a dummy scan item with both live_data and metadata/status_message info.
"""
dummy_live_data = {
"samx": {"samx": DummyData(val=[10, 20, 30], timestamps=[100, 200, 300])},
"samy": {"samy": DummyData(val=[5, 10, 15], timestamps=[100, 200, 300])},
"bpm4i": {"bpm4i": DummyData(val=[5, 6, 7], timestamps=[101, 201, 301])},
"async_device": {"async_device": DummyData(val=[1, 2, 3], timestamps=[11, 21, 31])},
}
dummy_scan = MagicMock()
dummy_scan.live_data = dummy_live_data
dummy_scan.metadata = {
"bec": {
"scan_id": "dummy",
"scan_report_devices": ["samx"],
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
}
}
dummy_scan.status_message = MagicMock()
dummy_scan.status_message.info = {
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
"scan_report_devices": ["samx"],
}
return dummy_scan
def inject_scan_history(widget, scan_history_factory, *history_args):
"""
Helper to inject scan history messages into client history.
"""
history_msgs = []
for scan_id, scan_number in history_args:
history_msgs.append(scan_history_factory(scan_id=scan_id, scan_number=scan_number))
widget.client.history = ScanHistory(widget.client, False)
for msg in history_msgs:
widget.client.history._scan_data[msg.scan_id] = msg
widget.client.history._scan_ids.append(msg.scan_id)
widget.client.queue.scan_storage.current_scan = None
return history_msgs

View File

@@ -1,37 +1,19 @@
import json
import time
from math import inf
from unittest import mock
from unittest.mock import MagicMock, PropertyMock, patch
import fakeredis
import h5py
import numpy as np
import pytest
from bec_lib import messages, service_config
from bec_lib.bec_service import messages
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib import messages
from bec_lib.messages import _StoredDataInfo
from bec_lib.scan_history import ScanHistory
from bec_qthemes import apply_theme
from ophyd._pyepics_shim import _dispatcher
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.tests.utils import (
DEVICES,
DMMock,
FakePositioner,
Positioner,
create_history_file,
process_all_deferred_deletes,
)
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
# This means that by default, error popups will raise exceptions during tests
@@ -47,6 +29,11 @@ def pytest_runtest_makereport(item, call):
item.stash["failed"] = rep.failed
def process_all_deferred_deletes(qapp):
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
qapp.processEvents(QEventLoop.AllEvents)
@pytest.fixture(autouse=True)
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
qapp = QApplication.instance()
@@ -59,6 +46,7 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus
# if the test failed, we don't want to check for open widgets as
# it simply pollutes the output
# stop pyepics dispatcher for leaking tests
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
if request.node.stash._storage.get("failed"):
@@ -83,36 +71,9 @@ def rpc_register():
RPCRegister.reset_singleton()
_REDIS_CONN: QtRedisConnector | None = None
def global_mock_qt_redis_connector(*_, **__):
global _REDIS_CONN
if _REDIS_CONN is None:
_REDIS_CONN = QtRedisConnector(bootstrap="localhost:1", redis_cls=fakeredis.FakeRedis)
return _REDIS_CONN
def mock_client(*_, **__):
with (
patch("bec_lib.client.DeviceManagerBase", DMMock),
patch("bec_lib.client.DAPPlugins"),
patch("bec_lib.client.Scans"),
patch("bec_lib.client.ScanManager"),
patch("bec_lib.bec_service.BECAccess"),
):
client = BECClient(
config=service_config.ServiceConfig(config={"redis": {"host": "localhost", "port": 1}}),
connector_cls=global_mock_qt_redis_connector,
)
client.start()
return client
@pytest.fixture(autouse=True)
def bec_dispatcher(threads_check): # pylint: disable=unused-argument
with mock.patch.object(bec_dispatcher_module, "BECClient", mock_client):
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
yield bec_dispatcher
bec_dispatcher.disconnect_all()
# clean BEC client
@@ -136,6 +97,103 @@ def suppress_message_box(monkeypatch):
monkeypatch.setattr(QMessageBox, "exec_", lambda *args, **kwargs: QMessageBox.Ok)
def create_widget(qtbot, widget, *args, **kwargs):
"""
Create a widget and add it to the qtbot for testing. This is a helper function that
should be used in all tests that require a widget to be created.
Args:
qtbot (fixture): pytest-qt fixture
widget (QWidget): widget class to be created
*args: positional arguments for the widget
**kwargs: keyword arguments for the widget
Returns:
QWidget: the created widget
"""
widget = widget(*args, **kwargs)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanHistoryMessage:
"""
Helper to create a history file with the given data.
The data should contain readout groups, e.g.
{
"baseline": {"samx": {"samx": {"value": [1, 2, 3], "timestamp": [100, 200, 300]}},
"monitored": {"bpm4i": {"bpm4i": {"value": [5, 6, 7], "timestamp": [101, 201, 301]}}},
"async": {"async_device": {"async_device": {"value": [1, 2, 3], "timestamp": [11, 21, 31]}}},
}
"""
with h5py.File(file_path, "w") as f:
_metadata = f.create_group("entry/collection/metadata")
_metadata.create_dataset("sample_name", data="test_sample")
metadata_bec = f.create_group("entry/collection/metadata/bec")
for key, value in metadata.items():
if isinstance(value, dict):
metadata_bec.create_group(key)
for sub_key, sub_value in value.items():
if isinstance(sub_value, list):
sub_value = json.dumps(sub_value)
metadata_bec[key].create_dataset(sub_key, data=sub_value)
elif isinstance(sub_value, dict):
for sub_sub_key, sub_sub_value in sub_value.items():
sub_sub_group = metadata_bec[key].create_group(sub_key)
# Handle _StoredDataInfo objects
if isinstance(sub_sub_value, _StoredDataInfo):
# Store the numeric shape
sub_sub_group.create_dataset("shape", data=sub_sub_value.shape)
# Store the dtype as a UTF-8 string
dt = sub_sub_value.dtype or ""
sub_sub_group.create_dataset(
"dtype", data=dt, dtype=h5py.string_dtype(encoding="utf-8")
)
continue
if isinstance(sub_sub_value, list):
json_val = json.dumps(sub_sub_value)
sub_sub_group.create_dataset(sub_sub_key, data=json_val)
elif isinstance(sub_sub_value, dict):
for k2, v2 in sub_sub_value.items():
val = json.dumps(v2) if isinstance(v2, list) else v2
sub_sub_group.create_dataset(k2, data=val)
else:
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
else:
metadata_bec[key].create_dataset(sub_key, data=sub_value)
else:
metadata_bec.create_dataset(key, data=value)
for group, devices in data.items():
readout_group = f.create_group(f"entry/collection/readout_groups/{group}")
for device, device_data in devices.items():
dev_group = f.create_group(f"entry/collection/devices/{device}")
for signal, signal_data in device_data.items():
signal_group = dev_group.create_group(signal)
for signal_key, signal_values in signal_data.items():
signal_group.create_dataset(signal_key, data=signal_values)
readout_group[device] = h5py.SoftLink(f"/entry/collection/devices/{device}")
msg = messages.ScanHistoryMessage(
scan_id=metadata["scan_id"],
scan_name=metadata["scan_name"],
exit_status=metadata["exit_status"],
file_path=file_path,
scan_number=metadata["scan_number"],
dataset_number=metadata["dataset_number"],
start_time=time.time(),
end_time=time.time(),
num_points=metadata["num_points"],
request_inputs=metadata["request_inputs"],
stored_data_info=metadata.get("stored_data_info"),
metadata={"scan_report_devices": metadata.get("scan_report_devices")},
)
return msg
@pytest.fixture
def grid_scan_history_msg(tmpdir):
x_grid, y_grid = np.meshgrid(np.linspace(-5, 5, 10), np.linspace(-5, 5, 10))
@@ -281,172 +339,3 @@ def scan_history_factory(tmpdir):
return create_history_file(file_path, data, metadata)
return _factory
@pytest.fixture(scope="function")
def mocked_client(bec_dispatcher):
# Ensure isinstance check for Positioner passes
original_isinstance = isinstance
def isinstance_mock(obj, class_info):
if class_info == Positioner and isinstance(obj, FakePositioner):
return True
return original_isinstance(obj, class_info)
with patch("builtins.isinstance", new=isinstance_mock):
yield bec_dispatcher.client
bec_dispatcher.client.connector.shutdown()
@pytest.fixture(scope="function")
def mock_client_w_devices(mocked_client):
mocked_client.device_manager.add_devices(DEVICES)
yield mocked_client
##################################################
# Client Fixture with DAP
##################################################
@pytest.fixture(scope="function")
def dap_plugin_message():
msg = messages.AvailableResourceMessage(
**{
"resource": {
"GaussianModel": {
"class": "LmfitService1D",
"user_friendly_name": "GaussianModel",
"class_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n ",
"run_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n \n Args:\n scan_item (ScanItem): Scan item or scan ID\n device_x (DeviceBase | str): Device name for x\n signal_x (DeviceBase | str): Signal name for x\n device_y (DeviceBase | str): Device name for y\n signal_y (DeviceBase | str): Signal name for y\n parameters (dict): Fit parameters\n ",
"run_name": "fit",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "scan_item",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "ScanItem | str",
},
{
"name": "device_x",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "signal_x",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "device_y",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "signal_y",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "parameters",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
"auto_fit_supported": True,
"params": {
"amplitude": {
"name": "amplitude",
"value": 1.0,
"vary": True,
"min": -inf,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"center": {
"name": "center",
"value": 0.0,
"vary": True,
"min": -inf,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"sigma": {
"name": "sigma",
"value": 1.0,
"vary": True,
"min": 0,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"fwhm": {
"name": "fwhm",
"value": 2.35482,
"vary": False,
"min": -inf,
"max": inf,
"expr": "2.3548200*sigma",
"brute_step": None,
"user_data": None,
},
"height": {
"name": "height",
"value": 0.3989423,
"vary": False,
"min": -inf,
"max": inf,
"expr": "0.3989423*amplitude/max(1e-15, sigma)",
"brute_step": None,
"user_data": None,
},
},
"class_args": [],
"class_kwargs": {"model": "GaussianModel"},
}
}
}
)
yield msg
@pytest.fixture(scope="function")
def mocked_client_with_dap(mocked_client, dap_plugin_message):
mocked_client.device_manager.add_devices(DEVICES)
dap_services = {
"BECClient": messages.StatusMessage(name="BECClient", status=1, info={}),
"DAPServer/LmfitService1D": messages.StatusMessage(
name="LmfitService1D", status=1, info={}
),
}
type(mocked_client).service_status = PropertyMock(return_value=dap_services)
mocked_client.connector.set(
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
)
# Patch the client's DAP attribute so that the available models include "GaussianModel"
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
mocked_client.dap._available_dap_plugins = patched_models
yield mocked_client

View File

@@ -1,16 +1,15 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest.mock import MagicMock
import pytest
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from .client_mocks import mocked_client
@pytest.fixture
def abort_button(qtbot, mocked_client):
widget = AbortButton(client=mocked_client)
widget.queue = MagicMock()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget

View File

@@ -1,9 +1,10 @@
import pytest
from qtpy.QtWidgets import QDoubleSpinBox, QLineEdit
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.setting_menus.axis_settings import AxisSettings
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture

View File

@@ -9,6 +9,8 @@ from bec_widgets.utils import BECConnector
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeSlot as Slot
from .client_mocks import mocked_client
class BECConnectorQObject(BECConnector, QObject): ...
@@ -132,7 +134,7 @@ def test_bec_connector_change_object_name(bec_connector):
assert not any(obj.objectName() == previous_name for obj in all_objects)
def test_bec_connector_export_settings(mocked_client):
def test_bec_connector_export_settings():
class MyWidget(BECConnector, QWidget):
def __init__(self, parent=None, client=None, **kwargs):

View File

@@ -4,45 +4,10 @@ import time
from unittest import mock
import pytest
from bec_lib import service_config
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback
def test_init_handles_client_and_config_arg():
# Client passed
self_mock = mock.MagicMock(_initialized=False)
with mock.patch.object(BECDispatcher, "start_cli_server"):
BECDispatcher.__init__(self_mock, client=mock.MagicMock(name="test_client"))
assert "test_client" in repr(self_mock.client)
# No client, service config object
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient") as client_cls,
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config=config)
client_cls.assert_called_with(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
# No client, service config string
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient"),
mock.patch("bec_widgets.utils.bec_dispatcher.ServiceConfig") as svc_cfg,
mock.patch("bec_widgets.utils.bec_dispatcher.isinstance", return_value=False),
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config="test_str")
svc_cfg.assert_called_with("test_str")
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
@pytest.fixture

View File

@@ -3,6 +3,8 @@ from bec_lib import messages
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from .client_mocks import mocked_client
@pytest.fixture
def bec_queue_msg_full():

View File

@@ -9,6 +9,8 @@ from bec_widgets.widgets.services.bec_status_box.bec_status_box import (
BECStatusBox,
)
from .client_mocks import mocked_client
@pytest.fixture
def service_status_fixture():

View File

@@ -5,6 +5,8 @@ from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
from .client_mocks import mocked_client
class _TestBusyWidget(BECWidget, QWidget):
def __init__(
@@ -27,7 +29,7 @@ def widget_busy(qtbot, mocked_client):
@pytest.fixture
def widget_idle(qtbot, mocked_client):
def widget_idle(qtbot):
w = _TestBusyWidget(client=mocked_client, start_busy=False)
qtbot.addWidget(w)
w.resize(320, 200)

View File

@@ -2,11 +2,12 @@ from qtpy.QtCore import Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QColorDialog
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
ColorButtonNative,
)
from .conftest import create_widget
def test_color_button_native(qtbot):
cb = create_widget(qtbot, ColorButtonNative)

View File

@@ -4,11 +4,12 @@ from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.tests.utils import create_widget
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
def test_color_validation_CSS():

View File

@@ -4,10 +4,12 @@ import pytest
from qtpy.QtCore import QPointF, Qt
from qtpy.QtGui import QTransform
from bec_widgets.tests.utils import create_widget
from bec_widgets.utils import Crosshair
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget
# pylint: disable = redefined-outer-name

View File

@@ -6,13 +6,14 @@ from bec_lib.scan_history import ScanHistory
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QComboBox, QVBoxLayout
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import (
CurveTree,
ScanIndexValidator,
)
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from tests.unit_tests.conftest import create_widget
##################################################
# CurveSetting
@@ -20,11 +21,11 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
@pytest.fixture
def curve_setting_fixture(qtbot, mock_client_w_devices):
def curve_setting_fixture(qtbot, mocked_client):
"""
Creates a CurveSetting widget targeting a mock or real Waveform widget.
"""
wf = create_widget(qtbot, Waveform, client=mock_client_w_devices)
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "auto"
curve_setting = create_widget(qtbot, CurveSetting, parent=None, target_widget=wf)
return curve_setting, wf

View File

@@ -1,8 +1,10 @@
import pytest
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture(scope="function")
def dap_combobox(qtbot, mocked_client):

View File

@@ -7,7 +7,7 @@ from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
# pylint: disable=unused-import
from .client_mocks import mocked_client
# pylint: disable=redefined-outer-name

View File

@@ -22,6 +22,8 @@ from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
from .client_mocks import mocked_client
@pytest.fixture
def developer_view(qtbot, mocked_client):

View File

@@ -14,6 +14,8 @@ from bec_widgets.widgets.services.device_browser.device_item.device_signal_displ
SignalDisplay,
)
from .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QListWidgetItem
@@ -27,8 +29,8 @@ if TYPE_CHECKING: # pragma: no cover
@pytest.fixture
def device_browser(qtbot, mock_client_w_devices):
dev_browser = DeviceBrowser(client=mock_client_w_devices)
def device_browser(qtbot, mocked_client):
dev_browser = DeviceBrowser(client=mocked_client)
dev_browser.dev["samx"].read_configuration = mock.MagicMock()
qtbot.addWidget(dev_browser)
qtbot.waitExposed(dev_browser)
@@ -146,8 +148,8 @@ def test_device_deletion(device_browser, qtbot):
qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000)
def test_signal_display(mock_client_w_devices, qtbot):
signal_display = SignalDisplay(client=mock_client_w_devices, device="test_device")
def test_signal_display(mocked_client, qtbot):
signal_display = SignalDisplay(client=mocked_client, device="test_device")
qtbot.addWidget(signal_display)
device_mock = mock.MagicMock()
signal_display.dev = {"test_device": device_mock}
@@ -156,10 +158,10 @@ def test_signal_display(mock_client_w_devices, qtbot):
device_mock.read_configuration.assert_called()
def test_signal_display_no_device(mock_client_w_devices, qtbot):
def test_signal_display_no_device(mocked_client, qtbot):
device_mock = mock.MagicMock()
mock_client_w_devices.device_manager.devices = {"test_device_1": device_mock}
signal_display = SignalDisplay(client=mock_client_w_devices, device="test_device_2")
mocked_client.client.device_manager.devices = {"test_device_1": device_mock}
signal_display = SignalDisplay(client=mocked_client, device="test_device_2")
qtbot.addWidget(signal_display)
assert (
signal_display._content_layout.itemAt(1).widget().text()
@@ -170,11 +172,11 @@ def test_signal_display_no_device(mock_client_w_devices, qtbot):
device_mock.read_configuration.assert_not_called()
def test_signal_display_omitted_not_added(mock_client_w_devices, qtbot):
def test_signal_display_omitted_not_added(mocked_client, qtbot):
device_mock = mock.MagicMock(spec=Device)
device_mock._info = {"signals": {"signal_1": {"kind_str": "omitted"}}}
signal_display = SignalDisplay(client=mock_client_w_devices, device="test_device_1")
signal_display = SignalDisplay(client=mocked_client, device="test_device_1")
signal_display.dev = {"test_device_1": device_mock}
signal_display._populate()

View File

@@ -6,6 +6,8 @@ from bec_widgets.widgets.progress.device_initialization_progress_bar.device_init
DeviceInitializationProgressBar,
)
from .client_mocks import mocked_client
@pytest.fixture
def progress_bar(qtbot, mocked_client):

View File

@@ -4,7 +4,6 @@ import pytest
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QWidget
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
BECDeviceFilter,
DeviceInputBase,
@@ -12,6 +11,9 @@ from bec_widgets.widgets.control.device_input.base_classes.device_input_base imp
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from .client_mocks import mocked_client
from .conftest import create_widget
# DeviceInputBase is meant to be mixed in a QWidget
class DeviceInputWidget(DeviceInputBase, QWidget):

View File

@@ -7,19 +7,21 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
DeviceLineEdit,
)
from .client_mocks import mocked_client
@pytest.fixture
def device_input_combobox(qtbot, mock_client_w_devices):
widget = DeviceComboBox(client=mock_client_w_devices)
def device_input_combobox(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def device_input_combobox_with_kwargs(qtbot, mock_client_w_devices):
def device_input_combobox_with_kwargs(qtbot, mocked_client):
widget = DeviceComboBox(
client=mock_client_w_devices,
client=mocked_client,
gui_id="test_gui_id",
device_filter=[BECDeviceFilter.POSITIONER],
default="samx",
@@ -72,17 +74,17 @@ def test_get_device_from_input_combobox_init(device_input_combobox):
@pytest.fixture
def device_input_line_edit(qtbot, mock_client_w_devices):
widget = DeviceLineEdit(client=mock_client_w_devices)
def device_input_line_edit(qtbot, mocked_client):
widget = DeviceLineEdit(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def device_input_line_edit_with_kwargs(qtbot, mock_client_w_devices):
def device_input_line_edit_with_kwargs(qtbot, mocked_client):
widget = DeviceLineEdit(
client=mock_client_w_devices,
client=mocked_client,
gui_id="test_gui_id",
device_filter=[BECDeviceFilter.POSITIONER],
default="samx",

View File

@@ -56,7 +56,8 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation.vali
ValidationListItem,
)
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
from tests.unit_tests.conftest import mocked_client
from .client_mocks import mocked_client
class TestConstants:
@@ -363,7 +364,7 @@ class TestDeviceTable:
assert hasattr(device_table, "client_callback_id")
def test_device_table_client_device_update_callback(
self, device_table: DeviceTable, mock_client_w_devices, qtbot
self, device_table: DeviceTable, mocked_client, qtbot
):
"""
Test that runs the client device update callback. This should update the status of devices in the table
@@ -374,7 +375,6 @@ class TestDeviceTable:
device from the client and run the callback. The table should update the status of the
removed device to CAN_CONNECT and all others to CONNECTED.
"""
mocked_client = mock_client_w_devices
device_configs_changed_calls = []
requested_update_for_multiple_device_validations = []

View File

@@ -43,6 +43,8 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophy
OphydValidation,
)
from .client_mocks import mocked_client
@pytest.fixture
def device_config() -> dict:

View File

@@ -4,7 +4,6 @@ import pytest
from bec_lib.device import Signal
from qtpy.QtWidgets import QWidget
from bec_widgets.tests.utils import create_widget
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
@@ -16,6 +15,9 @@ from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit
SignalLineEdit,
)
from .client_mocks import mocked_client
from .conftest import create_widget
class FakeSignal(Signal):
"""Fake signal to test the DeviceSignalInputBase."""
@@ -144,12 +146,12 @@ def test_signal_lineedit(device_signal_line_edit):
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
with mock.patch.object(mocked_client.callbacks, "remove"):
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):

View File

@@ -40,6 +40,8 @@ from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
)
from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import WorkSpaceManager
from .client_mocks import mocked_client
@pytest.fixture
def advanced_dock_area(qtbot, mocked_client):

View File

@@ -1,12 +1,14 @@
import pytest
from bec_widgets.tests.utils import create_widget
from bec_widgets.utils.filter_io import FilterIO
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture(scope="function")
def dap_mock(qtbot, mocked_client):

View File

@@ -16,6 +16,9 @@ from bec_widgets.widgets.plots.heatmap.heatmap import (
)
# pytest: disable=unused-import
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import create_dummy_scan_item
@pytest.fixture

View File

@@ -9,6 +9,8 @@ from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from .client_mocks import mocked_client
@pytest.fixture
def help_inspector(qtbot, mocked_client):

View File

@@ -4,10 +4,11 @@ import numpy as np
import pytest
from qtpy.QtCore import QPointF, Qt
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture

View File

@@ -5,7 +5,6 @@ from typing import Literal
import numpy as np
import pytest
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import (
CircularROI,
@@ -13,6 +12,8 @@ from bec_widgets.widgets.plots.roi.image_roi import (
RectangularROI,
ROIController,
)
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture(params=["rect", "circle", "ellipse"])

View File

@@ -4,8 +4,9 @@ import pytest
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QPointF
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.image.image import Image
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
##################################################
# Image widget base functionality tests

View File

@@ -11,6 +11,8 @@ from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from .client_mocks import mocked_client
base_path = os.path.dirname(bec_widgets.__file__)

View File

@@ -3,9 +3,11 @@ from unittest import mock
import numpy as np
import pytest
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture(scope="function")
def lmfit_dialog(qtbot, mocked_client):

View File

@@ -18,6 +18,8 @@ from bec_widgets.widgets.utility.logpanel._util import (
)
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from .client_mocks import mocked_client
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
TEST_LOG_MESSAGES = [

View File

@@ -4,6 +4,8 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.applications.main_app import BECMainApp
from bec_widgets.applications.views.view import ViewBase
from .client_mocks import mocked_client
ANIM_TEST_DURATION = 60 # ms

View File

@@ -5,7 +5,6 @@ from qtpy.QtCore import QEvent, QPoint, QPointF
from qtpy.QtGui import QEnterEvent
from qtpy.QtWidgets import QApplication, QFrame, QLabel
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.containers.main_window.addons.hover_widget import (
HoverWidget,
WidgetTooltip,
@@ -14,6 +13,9 @@ from bec_widgets.widgets.containers.main_window.addons.scroll_label import Scrol
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def bec_main_window(qtbot, mocked_client):

View File

@@ -8,6 +8,8 @@ from qtpy.QtWidgets import QFileDialog, QMessageBox
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from .client_mocks import mocked_client
@pytest.fixture
def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]:

View File

@@ -7,6 +7,7 @@ from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
from .client_mocks import mocked_client
from .test_scan_control import available_scans_message

View File

@@ -1,7 +1,9 @@
from qtpy.QtTest import QSignalSpy
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget
def test_motor_map_initialization(qtbot, mocked_client):

View File

@@ -1,7 +1,9 @@
import numpy as np
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from tests.unit_tests.client_mocks import mocked_client
from .conftest import create_widget
##################################################
# MultiWaveform widget base functionality tests

View File

@@ -13,6 +13,8 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
SeverityKind,
)
from .client_mocks import mocked_client
@pytest.fixture
def toast(qtbot):

View File

@@ -4,6 +4,8 @@ from qtpy.QtPdfWidgets import QPdfView
from bec_widgets.widgets.utility.pdf_viewer.pdf_viewer import PdfViewerWidget
from .client_mocks import mocked_client
@pytest.fixture
def pdf_viewer_widget(qtbot, mocked_client):

View File

@@ -1,8 +1,10 @@
import numpy as np
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from .client_mocks import mocked_client
from .conftest import create_widget
# pylint: disable=unused-import
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name

View File

@@ -7,7 +7,7 @@ from qtpy.QtCore import Qt, QTimer
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QPushButton
from bec_widgets.tests.utils import Positioner, create_widget
from bec_widgets.tests.utils import Positioner
from bec_widgets.widgets.control.device_control.positioner_box import (
PositionerBox,
PositionerControlLine,
@@ -16,6 +16,9 @@ from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit
DeviceLineEdit,
)
from .client_mocks import mocked_client
from .conftest import create_widget
class PositionerWithoutPrecision(Positioner):
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""

View File

@@ -2,9 +2,11 @@ from unittest import mock
import pytest
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def positioner_box_2d(qtbot, mocked_client):

View File

@@ -7,6 +7,8 @@ from qtpy.QtWidgets import QMessageBox
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
from .client_mocks import mocked_client
@pytest.fixture
def reset_button(qtbot, mocked_client):

View File

@@ -4,6 +4,8 @@ import pytest
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
from .client_mocks import mocked_client
@pytest.fixture
def resume_button(qtbot, mocked_client):

View File

@@ -10,6 +10,8 @@ from qtpy.QtGui import QColor
from bec_widgets.utils import Colors
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from .client_mocks import mocked_client
@pytest.fixture
def ring_progress_bar(qtbot, mocked_client):

View File

@@ -11,6 +11,8 @@ from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
RingProgressContainerWidget,
)
from .client_mocks import mocked_client
@pytest.fixture
def ring_container(qtbot, mocked_client):

View File

@@ -3,6 +3,7 @@ import pytest
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
from tests.unit_tests.client_mocks import mocked_client
@pytest.fixture

View File

@@ -9,6 +9,8 @@ from bec_widgets.cli.server import GUIServer
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat
from .client_mocks import mocked_client
class DummyWidget(BECConnector, QWidget):
def __init__(self, parent=None, client=None, **kwargs):

View File

@@ -11,6 +11,8 @@ from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
from .client_mocks import mocked_client
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name

View File

@@ -15,6 +15,8 @@ from bec_widgets.widgets.services.scan_history_browser.scan_history_browser impo
ScanHistoryBrowser,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_history_msg():

View File

@@ -14,6 +14,8 @@ from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
ScanProgressBar,
)
from .client_mocks import mocked_client
@pytest.fixture
def scan_progressbar(qtbot, mocked_client):

View File

@@ -1,8 +1,7 @@
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import numpy as np
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
ScatterCurveConfig,
ScatterDeviceSignal,
@@ -11,6 +10,9 @@ from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterW
from bec_widgets.widgets.plots.scatter_waveform.settings.scatter_curve_setting import (
ScatterCurveSettings,
)
from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client
from .conftest import create_widget
def test_waveform_initialization(qtbot, mocked_client):
@@ -51,16 +53,14 @@ def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeyp
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
dummy_scan = create_dummy_scan_item()
mocked_client.history = MagicMock()
# .get_by_scan_id() typically returns historical data, but we abuse it here
# to return mock live data
mocked_client.history.get_by_scan_id.return_value = dummy_scan
mocked_client.history.__getitem__.return_value = dummy_scan
swf.plot("samx", "samy", "bpm4i", label="test_curve")
swf.update_with_scan_history(scan_id="dummy")
qtbot.waitUntil(lambda: swf.scan_item == dummy_scan, timeout=500)
qtbot.wait(200)
qtbot.wait(500)
assert swf.scan_item == dummy_scan
x_data, y_data = swf.main_curve.getData()
np.testing.assert_array_equal(x_data, [10, 20, 30])

View File

@@ -11,6 +11,8 @@ from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_b
)
from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel
from .client_mocks import mocked_client
SAMX_INFO_DICT = {
"signals": {
"readback": {

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import pytest
from bec_lib.messages import BeamlineConditionUpdateEntry
from qtpy.QtWidgets import QToolBar
from bec_widgets.utils.toolbars.actions import StatusIndicatorAction, StatusIndicatorWidget
from bec_widgets.utils.toolbars.status_bar import BECStatusBroker, StatusToolBar
from .client_mocks import mocked_client
from .conftest import create_widget
class TestStatusIndicators:
"""Widget/action level tests independent of broker wiring."""
def test_indicator_widget_state_and_text(self, qtbot):
widget = StatusIndicatorWidget(text="Ready", state="success")
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.set_state("warning")
widget.set_text("Alert")
assert widget._state.value == "warning"
assert widget._text_label.text() == "Alert"
def test_indicator_action_updates_widget_and_action(self, qtbot):
qt_toolbar = QToolBar()
qtbot.addWidget(qt_toolbar)
action = StatusIndicatorAction(text="Ready", tooltip="Initial")
action.add_to_toolbar(qt_toolbar, qt_toolbar)
action.set_tooltip("Updated tooltip")
action.set_text("Running")
assert action.action.toolTip() == "Updated tooltip"
assert action.widget.toolTip() == "Updated tooltip" # type: ignore[union-attr]
assert action.widget._text_label.text() == "Running" # type: ignore[union-attr]
class TestStatusBar:
"""Status bar + broker integration using fake redis client (mocked_client)."""
@pytest.fixture(params=[{}, {"names": ["alpha"]}])
def status_toolbar(self, qtbot, mocked_client, request):
broker = BECStatusBroker(client=mocked_client)
toolbar = create_widget(qtbot, StatusToolBar, **request.param)
yield toolbar
broker.reset_singleton()
def test_allowed_names_precreates_placeholder(self, status_toolbar):
status_toolbar.broker.refresh_available = lambda: None
status_toolbar.refresh_from_broker()
# We parametrize the fixture so one invocation has allowed_names set.
if status_toolbar.allowed_names:
name = next(iter(status_toolbar.allowed_names))
assert status_toolbar.components.exists(name)
act = status_toolbar.components.get_action(name)
assert isinstance(act, StatusIndicatorAction)
assert act.widget._text_label.text() == name # type: ignore[union-attr]
def test_on_available_adds_and_removes(self, status_toolbar):
conditions = [
BeamlineConditionUpdateEntry(name="c1", title="Cond 1", condition_type="test"),
BeamlineConditionUpdateEntry(name="c2", title="Cond 2", condition_type="test"),
]
status_toolbar.on_available_updated(conditions)
assert status_toolbar.components.exists("c1")
assert status_toolbar.components.exists("c2")
conditions2 = [
BeamlineConditionUpdateEntry(name="c1", title="Cond 1", condition_type="test")
]
status_toolbar.on_available_updated(conditions2)
assert status_toolbar.components.exists("c1")
assert not status_toolbar.components.exists("c2")
def test_on_status_updated_sets_title_and_message(self, status_toolbar):
status_toolbar.add_status_item("beam", text="Initial", state="default", tooltip=None)
payload = {"name": "beam", "status": "warning", "title": "New Title", "message": "Detail"}
status_toolbar.on_status_updated("beam", payload)
action = status_toolbar.components.get_action("beam")
assert isinstance(action, StatusIndicatorAction)
assert action.widget._text_label.text() == "New Title" # type: ignore[union-attr]
assert action.action.toolTip() == "Detail"
def test_on_status_updated_keeps_existing_text_when_no_title(self, status_toolbar):
status_toolbar.add_status_item("beam", text="Keep Me", state="default", tooltip=None)
payload = {"name": "beam", "status": "normal", "message": "Note"}
status_toolbar.on_status_updated("beam", payload)
action = status_toolbar.components.get_action("beam")
assert isinstance(action, StatusIndicatorAction)
assert action.widget._text_label.text() == "Keep Me" # type: ignore[union-attr]
assert action.action.toolTip() == "Note"

View File

@@ -4,6 +4,8 @@ import pytest
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from .client_mocks import mocked_client
@pytest.fixture
def stop_button(qtbot, mocked_client):

View File

@@ -2,6 +2,8 @@ import pytest
from bec_widgets.widgets.editors.text_box.text_box import DEFAULT_TEXT, TextBox
from .client_mocks import mocked_client
@pytest.fixture
def text_box_widget(qtbot, mocked_client):

View File

@@ -3,10 +3,12 @@ from unittest import mock
import pyqtgraph as pg
import pytest
from bec_widgets.tests.utils import create_widget
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def dap_combo_box(qtbot, mocked_client):

View File

@@ -3,10 +3,12 @@ from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from .client_mocks import mocked_client
@pytest.fixture
def plot_widget_with_arrow_item(qtbot, mocked_client):
widget = Waveform(client=mocked_client)
widget = Waveform(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
@@ -15,7 +17,7 @@ def plot_widget_with_arrow_item(qtbot, mocked_client):
@pytest.fixture
def plot_widget_with_tick_item(qtbot, mocked_client):
widget = Waveform(client=mocked_client)
widget = Waveform(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)

View File

@@ -12,13 +12,22 @@ from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.plots.plot_base import UIMode
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
from tests.unit_tests.client_mocks import (
DummyData,
create_dummy_scan_item,
dap_plugin_message,
inject_scan_history,
mocked_client,
mocked_client_with_dap,
)
from .conftest import create_widget
# pylint: disable=unexpected-keyword-arg

View File

@@ -12,6 +12,8 @@ from bec_widgets.widgets.editors.web_console.web_console import (
_web_console_registry,
)
from .client_mocks import mocked_client
@pytest.fixture
def mocked_server_startup():
@@ -187,10 +189,10 @@ def test_bec_shell_startup_contains_gui_id(bec_shell_widget):
assert bec_shell._is_bec_shell
assert bec_shell._unique_id == "bec_shell"
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None):
assert bec_shell.startup_cmd == "bec --nogui"
assert bec_shell.startup_cmd == "bec --nogui"
with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"):
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server") as mock_cli_server:
mock_cli_server.gui_id = "test_gui_id"
assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"

View File

@@ -3,6 +3,8 @@ from qtpy.QtCore import QUrl
from bec_widgets.widgets.editors.website.website import WebsiteWidget
from .client_mocks import mocked_client
@pytest.fixture
def website_widget(qtbot, mocked_client):

View File

@@ -2,8 +2,8 @@ import pytest
from qtpy.QtCore import QPoint, QSize, Qt
from qtpy.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.utility.widget_finder.widget_finder import WidgetFinderComboBox
from tests.unit_tests.conftest import create_widget
@pytest.fixture