feat(scan_control): add scan filter and docstring info dialogs

This commit is contained in:
2026-08-04 14:51:32 +02:00
committed by Jan Wyzula
parent 4ad1d4b115
commit e06232ee21
6 changed files with 861 additions and 21 deletions
@@ -3,17 +3,20 @@ from types import NoneType, SimpleNamespace
from typing import Optional
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from pydantic import BaseModel, Field
from qtpy.QtCore import Qt, Signal
from qtpy.QtCore import QSignalBlocker, Qt, Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -23,8 +26,11 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.scan_control.scan_docstring import render_scan_tooltip_html
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
from bec_widgets.widgets.control.scan_control.scan_info_dialog import ScanInfoDialog
from bec_widgets.widgets.control.scan_control.scan_selection_dialog import ScanSelectionDialog
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
@@ -49,6 +55,7 @@ class ScanControl(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
SUPPORTED_SCAN_BASE_CLASSES = {"ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"}
scan_started = Signal()
scan_selected = Signal(str)
@@ -87,7 +94,8 @@ class ScanControl(BECWidget, QWidget):
# Widget Default Parameters
self.config.default_scan = default_scan
self.config.allowed_scans = allowed_scans
if allowed_scans is not None:
self.config.allowed_scans = allowed_scans
self._scan_metadata: dict | None = None
self._metadata_form = ScanMetadata(parent=self)
@@ -96,7 +104,9 @@ class ScanControl(BECWidget, QWidget):
self._hide_scan_control_buttons = False
self._hide_metadata = False
self._hide_scan_selection_combobox = False
self._hide_scan_selector_settings_button = False
self._scan_info_adapter = ScanInfoAdapter()
self._scan_info_dialog: ScanInfoDialog | None = None
# Create and set main layout
self._init_UI()
@@ -119,8 +129,21 @@ class ScanControl(BECWidget, QWidget):
scan_selection_layout = QHBoxLayout()
self.comboBox_scan_selection_label = QLabel("Scan:", self.scan_selection_group)
self.comboBox_scan_selection = QComboBox(self.scan_selection_group)
self.scan_info_button = QToolButton(self.scan_selection_group)
self.scan_info_button.setIcon(material_icon("info", size=(20, 20), convert_to_pixmap=False))
self.scan_info_button.setAutoRaise(True)
self.scan_info_button.setToolTip("Show information about the selected scan")
self.scan_info_button.setAccessibleName("Scan information")
self.scan_selector_settings_button = QToolButton(self.scan_selection_group)
self.scan_selector_settings_button.setAutoRaise(True)
self.scan_selector_settings_button.setIcon(
material_icon("filter_list", size=(20, 20), convert_to_pixmap=False)
)
self.scan_selector_settings_button.setToolTip("Choose scans shown in the selector")
scan_selection_layout.addWidget(self.comboBox_scan_selection_label, 0)
scan_selection_layout.addWidget(self.comboBox_scan_selection, 1)
scan_selection_layout.addWidget(self.scan_info_button, 0)
scan_selection_layout.addWidget(self.scan_selector_settings_button, 0)
self.scan_selection_group.layout().addLayout(scan_selection_layout)
# Button to reload the last scan parameters on demand.
@@ -154,6 +177,8 @@ class ScanControl(BECWidget, QWidget):
# Connect signals
self.comboBox_scan_selection.view().pressed.connect(self.save_current_scan_parameters)
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selection_changed)
self.scan_info_button.clicked.connect(self.show_selected_scan_info)
self.scan_selector_settings_button.clicked.connect(self.show_scan_selector_settings)
self.button_run_scan.clicked.connect(self.run_scan)
self.scan_selected.connect(self.scan_select)
@@ -178,31 +203,138 @@ class ScanControl(BECWidget, QWidget):
self._metadata_form.form_data_cleared.connect(self.update_scan_metadata)
self._metadata_form.validate_form()
def _scan_docstring(self, scan_name: str) -> str | None:
scan_info = self.available_scans.get(scan_name, {})
docstring = scan_info.get("doc") if isinstance(scan_info, dict) else None
return docstring if isinstance(docstring, str) else None
@SafeSlot()
@SafeSlot(bool)
def show_selected_scan_info(self, *_args) -> None:
"""Show documentation for the currently selected scan without blocking the GUI."""
self.show_scan_info(self.comboBox_scan_selection.currentText())
@SafeSlot(str)
def show_scan_info(self, scan_name: str) -> None:
"""Show documentation for a specific scan."""
if self._scan_info_dialog is None:
self._scan_info_dialog = ScanInfoDialog(self)
self._scan_info_dialog.show_scan(scan_name, self._scan_docstring(scan_name))
def populate_scans(self):
"""Populates the scan selection combo box with available scans from BEC session."""
self.available_scans = self.client.connector.get(
MessageEndpoints.available_scans()
).resource
if self.config.allowed_scans is None:
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"]
self._update_scan_selector()
def _is_scan_supported(scan_name):
scan_info = self.available_scans[scan_name]
return (
scan_info.get("base_class") in supported_scans
and self._scan_info_adapter.has_scan_ui_config(scan_info)
and not scan_name.startswith("_")
)
allowed_scans = filter(_is_scan_supported, self.available_scans.keys())
def _supported_scan_names(self) -> list[str]:
"""Return available scans that can be rendered by this widget."""
return [
scan_name
for scan_name, scan_info in self.available_scans.items()
if scan_info.get("base_class") in self.SUPPORTED_SCAN_BASE_CLASSES
and self._scan_info_adapter.has_scan_ui_config(scan_info)
and not scan_name.startswith("_")
]
def _update_scan_selector(self) -> None:
"""Apply the configured scan filter while preserving the current selection."""
current_scan = self.comboBox_scan_selection.currentText()
if current_scan:
self.save_current_scan_parameters()
allowed_scans = self.allowed_scans
if allowed_scans is None:
visible_scans = self._supported_scan_names()
else:
allowed_scans = self.config.allowed_scans
self.comboBox_scan_selection.addItems(allowed_scans)
# An explicit filter overrides the support filter and keeps the caller's order;
# entries not currently available stay in the filter and reappear once published.
visible_scans = [scan for scan in allowed_scans if scan in self.available_scans]
with QSignalBlocker(self.comboBox_scan_selection):
self.comboBox_scan_selection.clear()
self.comboBox_scan_selection.addItems(visible_scans)
for index, scan_name in enumerate(visible_scans):
self.comboBox_scan_selection.setItemData(
index,
render_scan_tooltip_html(scan_name, self._scan_docstring(scan_name)),
Qt.ItemDataRole.ToolTipRole,
)
if current_scan in visible_scans:
self.comboBox_scan_selection.setCurrentText(current_scan)
self.scan_info_button.setEnabled(bool(visible_scans))
self._update_run_button_state()
self._update_selected_scan_tooltip()
if self.comboBox_scan_selection.currentText() != current_scan:
self.on_scan_selection_changed(self.comboBox_scan_selection.currentIndex())
def _update_run_button_state(self) -> None:
"""Start requires a selected scan and valid metadata."""
has_scan = bool(self.comboBox_scan_selection.currentText())
self.button_run_scan.setEnabled(has_scan and self._scan_metadata is not None)
def _update_selected_scan_tooltip(self) -> None:
"""Mirror the selected item's documentation tooltip on the closed combo box."""
index = self.comboBox_scan_selection.currentIndex()
tooltip = self.comboBox_scan_selection.itemData(index, Qt.ItemDataRole.ToolTipRole)
self.comboBox_scan_selection.setToolTip(tooltip or "")
@SafeSlot()
@SafeSlot(bool)
def show_scan_selector_settings(self, *_):
"""Open the scan filter dialog and apply accepted changes."""
scan_names = self._supported_scan_names()
allowed_scans = self.allowed_scans
if allowed_scans is not None:
# Keep configured entries visible in the dialog even when currently unsupported.
scan_names += [scan for scan in allowed_scans if scan not in scan_names]
dialog = ScanSelectionDialog(
scan_names=scan_names,
selected_scans=scan_names if allowed_scans is None else allowed_scans,
scan_docs={scan_name: self._scan_docstring(scan_name) for scan_name in scan_names},
parent=self,
)
try:
selected_scans = (
dialog.selected_scans() if dialog.exec() == QDialog.DialogCode.Accepted else None
)
finally:
dialog.deleteLater()
if selected_scans is not None:
# Everything checked means "no filter", so scans added later show up as well.
self.allowed_scans = None if selected_scans == scan_names else selected_scans
@SafeProperty(list)
def allowed_scans(self) -> list[str] | None:
"""Scan filter for the selector; None shows every supported scan, including future ones."""
allowed_scans = getattr(self.config, "allowed_scans", None)
return None if allowed_scans is None else list(allowed_scans)
@allowed_scans.setter
def allowed_scans(self, scan_names: list[str] | str | None):
"""Set the scans displayed in the selector; None clears the filter."""
if isinstance(scan_names, str):
scan_names = [scan_names]
if scan_names is not None:
scan_names = list(dict.fromkeys(scan_names))
self.config.allowed_scans = scan_names
self._update_scan_selector()
@SafeProperty(bool)
def hide_scan_selector_settings_button(self) -> bool:
"""Whether the button for configuring visible scans is hidden."""
return self._hide_scan_selector_settings_button
@hide_scan_selector_settings_button.setter
def hide_scan_selector_settings_button(self, hide: bool):
self._hide_scan_selector_settings_button = bool(hide)
self.scan_selector_settings_button.setVisible(not self._hide_scan_selector_settings_button)
def on_scan_selection_changed(self, index: int):
"""Callback for scan selection combo box"""
selected_scan_name = self.comboBox_scan_selection.currentText()
self._update_selected_scan_tooltip()
self.scan_selected.emit(selected_scan_name)
self.restore_scan_parameters(selected_scan_name)
@@ -539,23 +671,27 @@ class ScanControl(BECWidget, QWidget):
@SafeSlot(NoneType)
def update_scan_metadata(self, md: dict | None):
self._scan_metadata = md
if md is None:
self.button_run_scan.setEnabled(False)
else:
self.button_run_scan.setEnabled(True)
self._update_run_button_state()
@SafeSlot(popup_error=True)
def run_scan(self):
"""Starts the selected scan with the given parameters."""
scan_name = self.comboBox_scan_selection.currentText()
if not scan_name:
return
args, kwargs = self.get_scan_parameters()
self.scan_args.emit(args)
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
scan_function = getattr(self.scans, scan_name)
if callable(scan_function):
self.scan_started.emit()
scan_function(*args, **kwargs)
def cleanup(self):
"""Cleanup the scan control widget."""
if self._scan_info_dialog is not None:
self._scan_info_dialog.close()
self._scan_info_dialog.deleteLater()
self._scan_info_dialog = None
super().cleanup()
@@ -0,0 +1,176 @@
"""Rendering helpers for scan documentation."""
from __future__ import annotations
import html
import inspect
import re
import textwrap
_SECTION_TITLES = {
"args": "Arguments",
"arguments": "Arguments",
"parameters": "Arguments",
"keyword args": "Keyword arguments",
"keyword arguments": "Keyword arguments",
"kwargs": "Keyword arguments",
"attributes": "Attributes",
"returns": "Returns",
"yields": "Yields",
"raises": "Raises",
"examples": "Examples",
"example": "Examples",
"notes": "Notes",
"note": "Notes",
"warnings": "Warnings",
"warning": "Warnings",
"see also": "See also",
}
_FIELD_SECTIONS = {"Arguments", "Keyword arguments", "Attributes", "Returns", "Yields", "Raises"}
_TYPED_FIELD_PATTERN = re.compile(r"^(.+?)\s+\((.+?)\)\s*:\s*(.*)$")
_FIELD_PATTERN = re.compile(r"^([^:]+)\s*:\s*(.*)$")
_TOOLTIP_SUMMARY_LIMIT = 320
_TOOLTIP_ARGUMENT_LIMIT = 8
def _split_blocks(docstring: str) -> list[tuple[str | None, list[str]]]:
"""Split a docstring into titled sections and untitled prose blocks, in order."""
blocks: list[tuple[str | None, list[str]]] = [(None, [])]
for line in inspect.cleandoc(docstring).splitlines():
stripped = line.strip()
unindented = line == line.lstrip()
title = _SECTION_TITLES.get(stripped.removesuffix(":").lower())
if title is not None and unindented:
blocks.append((title, []))
continue
if blocks[-1][0] is not None and stripped and unindented:
# Unindented prose ends the indented section body and belongs to the surrounding text.
blocks.append((None, []))
blocks[-1][1].append(line)
return blocks
def _paragraph_text(lines: list[str]) -> str:
text = textwrap.dedent("\n".join(lines)).strip()
if not text:
return ""
first_paragraph = re.split(r"\n\s*\n", text, maxsplit=1)[0]
return " ".join(line.strip() for line in first_paragraph.splitlines())
def _paragraphs_to_html(lines: list[str]) -> str:
text = textwrap.dedent("\n".join(lines)).strip()
if not text:
return ""
paragraphs = re.split(r"\n\s*\n", text)
return "".join(
f"<p>{html.escape(' '.join(line.strip() for line in paragraph.splitlines()))}</p>"
for paragraph in paragraphs
if paragraph.strip()
)
def _parse_fields(lines: list[str]) -> list[tuple[str, str | None, str]]:
text = textwrap.dedent("\n".join(lines)).strip()
if not text:
return []
fields: list[tuple[str, str | None, str]] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
if raw_line == raw_line.lstrip():
match = _TYPED_FIELD_PATTERN.match(line)
if match:
fields.append(
(match.group(1).strip(), match.group(2).strip(), match.group(3).strip())
)
continue
match = _FIELD_PATTERN.match(line)
if match:
fields.append((match.group(1).strip(), None, match.group(2).strip()))
continue
if not fields:
return []
name, field_type, description = fields[-1]
fields[-1] = (name, field_type, " ".join(filter(None, (description, line))))
return fields
def _fields_to_html(fields: list[tuple[str, str | None, str]]) -> str:
rows = []
for name, field_type, description in fields:
type_html = (
f"<br><small>{html.escape(field_type)}</small>" if field_type is not None else ""
)
rows.append(
"<tr>"
f'<td valign="top"><b><code>{html.escape(name)}</code></b>{type_html}</td>'
f'<td valign="top">{html.escape(description)}</td>'
"</tr>"
)
return '<table cellspacing="6" cellpadding="2">' + "".join(rows) + "</table>"
def render_scan_docstring_html(scan_name: str, docstring: str | None) -> str:
"""Render a Google-style scan docstring as theme-neutral, safe HTML."""
title = html.escape(scan_name)
if not isinstance(docstring, str) or not docstring.strip():
return f"<h1>{title}</h1><p><i>No documentation is available for this scan.</i></p>"
body = [f"<h1>{title}</h1>"]
for section_title, lines in _split_blocks(docstring):
if section_title is None:
body.append(_paragraphs_to_html(lines))
continue
body.append(f"<h2>{html.escape(section_title)}</h2>")
if section_title == "Examples":
example = textwrap.dedent("\n".join(lines)).strip()
if example:
body.append(f"<pre>{html.escape(example)}</pre>")
continue
fields = _parse_fields(lines) if section_title in _FIELD_SECTIONS else []
body.append(_fields_to_html(fields) if fields else _paragraphs_to_html(lines))
return "".join(body)
def render_scan_tooltip_html(scan_name: str, docstring: str | None) -> str:
"""Render a compact scan summary for combo-box hover tooltips."""
title = html.escape(scan_name)
if not isinstance(docstring, str) or not docstring.strip():
return f"<b>{title}</b><br><i>No documentation is available for this scan.</i>"
blocks = _split_blocks(docstring)
summary = _paragraph_text(blocks[0][1])
if len(summary) > _TOOLTIP_SUMMARY_LIMIT:
summary = summary[: _TOOLTIP_SUMMARY_LIMIT - 1].rstrip() + ""
body = [f"<b>{title}</b>"]
if summary:
body.append(f"<p>{html.escape(summary)}</p>")
parameters = []
for section_title, lines in blocks:
if section_title in {"Arguments", "Keyword arguments"}:
parameters.extend(_parse_fields(lines))
if parameters:
labels = [
f"{name}: {field_type}" if field_type else name
for name, field_type, _description in parameters[:_TOOLTIP_ARGUMENT_LIMIT]
]
if len(parameters) > _TOOLTIP_ARGUMENT_LIMIT:
labels.append("")
body.append(f"<p><b>Parameters:</b> {html.escape(', '.join(labels))}</p>")
body.append("<small>Use the info button for full documentation.</small>")
return "".join(body)
@@ -0,0 +1,53 @@
"""Reusable dialog for displaying styled scan documentation."""
from qtpy.QtGui import QPalette
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QTextBrowser, QVBoxLayout, QWidget
from bec_widgets.widgets.control.scan_control.scan_docstring import render_scan_docstring_html
class ScanInfoDialog(QDialog):
"""Modeless, theme-aware viewer for a scan docstring."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setModal(False)
self.resize(640, 480)
layout = QVBoxLayout(self)
self.text_browser = QTextBrowser(self)
self.text_browser.setReadOnly(True)
self.text_browser.setOpenExternalLinks(False)
layout.addWidget(self.text_browser)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self)
buttons.rejected.connect(self.close)
layout.addWidget(buttons)
def _update_style(self) -> None:
palette = self.text_browser.palette()
accent = palette.color(QPalette.ColorRole.Link).name()
muted = palette.color(QPalette.ColorRole.PlaceholderText).name()
alternate_base = palette.color(QPalette.ColorRole.AlternateBase).name()
self.text_browser.document().setDefaultStyleSheet(f"""
h1 {{ color: {accent}; margin-bottom: 12px; }}
h2 {{ color: {accent}; margin-top: 18px; margin-bottom: 6px; }}
small {{ color: {muted}; }}
table {{ margin-left: 2px; }}
td {{ padding: 3px 8px 3px 0; }}
pre {{
background-color: {alternate_base};
padding: 8px;
margin: 4px 0;
white-space: pre-wrap;
}}
""")
def show_scan(self, scan_name: str, docstring: str | None) -> None:
"""Render and show documentation for ``scan_name``."""
self.setWindowTitle(f"Scan information: {scan_name}")
self._update_style()
self.text_browser.setHtml(render_scan_docstring_html(scan_name, docstring))
self.show()
self.raise_()
self.activateWindow()
@@ -0,0 +1,113 @@
from collections.abc import Iterable, Mapping
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QCheckBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QListWidget,
QListWidgetItem,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.widgets.control.scan_control.scan_info_dialog import ScanInfoDialog
class _RowCheckBox(QCheckBox):
"""Checkbox whose entire row-sized widget is an activation target."""
def hitButton(self, position) -> bool:
return self.isEnabled() and self.rect().contains(position)
class ScanSelectionDialog(QDialog):
"""Dialog for choosing which scans are shown in a scan selector."""
def __init__(
self,
scan_names: Iterable[str],
selected_scans: Iterable[str],
scan_docs: Mapping[str, str | None] | None = None,
parent=None,
) -> None:
super().__init__(parent)
self.setWindowTitle("Select available scans")
self._scan_names = list(scan_names)
self._scan_docs = dict(scan_docs or {})
self._scan_checkboxes: dict[str, _RowCheckBox] = {}
self._scan_info_buttons: dict[str, QToolButton] = {}
self._scan_info_dialog: ScanInfoDialog | None = None
selected = set(selected_scans)
self.scan_list = QListWidget(self)
self.scan_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
for scan_name in self._scan_names:
item = QListWidgetItem(self.scan_list)
item.setData(Qt.ItemDataRole.UserRole, scan_name)
row = QWidget(self.scan_list)
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
checkbox = _RowCheckBox(scan_name, row)
checkbox.setChecked(scan_name in selected)
checkbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
row_layout.addWidget(checkbox, 1)
info_button = QToolButton(row)
info_button.setAutoRaise(True)
info_button.setIcon(material_icon("info", size=(18, 18), convert_to_pixmap=False))
info_button.setToolTip(f"Show information about {scan_name}")
info_button.setAccessibleName(f"Information for {scan_name}")
info_button.clicked.connect(
lambda _checked=False, name=scan_name: self.show_scan_info(name)
)
row_layout.addWidget(info_button, 0)
self._scan_checkboxes[scan_name] = checkbox
self._scan_info_buttons[scan_name] = info_button
item.setSizeHint(row.sizeHint())
self.scan_list.setItemWidget(item, row)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
buttons.setContentsMargins(4, 4, 4, 4)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self.scan_list)
layout.addWidget(buttons)
self.resize(360, 420)
def checkbox_for_scan(self, scan_name: str) -> QCheckBox:
"""Return the checkbox belonging to ``scan_name``."""
return self._scan_checkboxes[scan_name]
def info_button_for_scan(self, scan_name: str) -> QToolButton:
"""Return the information button belonging to ``scan_name``."""
return self._scan_info_buttons[scan_name]
def show_scan_info(self, scan_name: str) -> None:
"""Show documentation for a scan while keeping this selector open."""
if self._scan_info_dialog is None:
self._scan_info_dialog = ScanInfoDialog(self)
self._scan_info_dialog.show_scan(scan_name, self._scan_docs.get(scan_name))
def selected_scans(self) -> list[str]:
"""Return checked scans in the same order as displayed."""
selected = []
for scan_name in self._scan_names:
if self._scan_checkboxes[scan_name].isChecked():
selected.append(scan_name)
return selected
+241 -1
View File
@@ -5,13 +5,16 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
from qtpy.QtCore import QModelIndex, Qt
from qtpy.QtCore import QModelIndex, QPoint, Qt
from qtpy.QtWidgets import QCheckBox, QDialog, QStyle
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.control.scan_control.scan_control import ScanControlConfig
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
from bec_widgets.widgets.control.scan_control.scan_selection_dialog import ScanSelectionDialog
from .client_mocks import mocked_client
@@ -25,6 +28,13 @@ available_scans_message = AvailableResourceMessage(
"line_scan": {
"class": "LineScan",
"base_class": "ScanBase",
"doc": (
"Run a line scan.\n\n"
"Args:\n"
" device (DeviceBase | str): Device to move.\n\n"
"Examples:\n"
" >>> scans.line_scan(samx, 0, 1)"
),
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"gui_config": {
"scan_class_name": "LineScan",
@@ -119,6 +129,7 @@ available_scans_message = AvailableResourceMessage(
"grid_scan": {
"class": "Scan",
"base_class": "ScanBase",
"doc": "Run a grid scan over one or more devices.",
"arg_input": {"device": "device", "start": "float", "stop": "float", "steps": "int"},
"gui_config": {
"scan_class_name": "Scan",
@@ -281,6 +292,235 @@ def test_populate_scans(scan_control, mocked_client):
assert sorted(items) == sorted(expected_scans)
def test_scan_selector_items_and_combo_show_doc_tooltips(scan_control):
line_index = scan_control.comboBox_scan_selection.findText("line_scan")
line_tooltip = scan_control.comboBox_scan_selection.itemData(
line_index, Qt.ItemDataRole.ToolTipRole
)
assert "line_scan" in line_tooltip
assert "Run a line scan." in line_tooltip
assert "Parameters:" in line_tooltip
assert "device: DeviceBase | str" in line_tooltip
assert scan_control.comboBox_scan_selection.toolTip() == line_tooltip
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert "grid_scan" in scan_control.comboBox_scan_selection.toolTip()
assert "Run a grid scan" in scan_control.comboBox_scan_selection.toolTip()
def test_scan_info_button_shows_styled_selected_scan_docstring(scan_control, qtbot):
assert not scan_control.scan_info_button.icon().isNull()
assert scan_control.scan_info_button.accessibleName() == "Scan information"
with patch.object(scan_control.client.connector, "get") as connector_get:
qtbot.mouseClick(scan_control.scan_info_button, Qt.MouseButton.LeftButton)
connector_get.assert_not_called()
assert scan_control._scan_info_dialog.isVisible()
assert not scan_control._scan_info_dialog.isModal()
assert scan_control._scan_info_dialog.windowTitle() == "Scan information: line_scan"
plain_text = scan_control._scan_info_dialog.text_browser.toPlainText()
assert "line_scan" in plain_text
assert "Arguments" in plain_text
assert "device" in plain_text
assert "Examples" in plain_text
assert ">>> scans.line_scan" in plain_text
style = scan_control._scan_info_dialog.text_browser.document().defaultStyleSheet()
assert "h1" in style
assert "pre" in style
def test_scan_info_button_handles_missing_docstring(scan_control, qtbot):
scan_control.available_scans["line_scan"].pop("doc")
qtbot.mouseClick(scan_control.scan_info_button, Qt.MouseButton.LeftButton)
assert "No documentation is available for this scan." in (
scan_control._scan_info_dialog.text_browser.toPlainText()
)
def test_allowed_scans_property_filters_selector(scan_control):
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
scan_control.allowed_scans = ["line_scan", "unknown_scan", "line_scan"]
# The configured filter is kept verbatim (deduplicated) so that scans that are not
# available right now reappear once the scan server publishes them.
assert scan_control.allowed_scans == ["line_scan", "unknown_scan"]
assert scan_control.config.allowed_scans == ["line_scan", "unknown_scan"]
assert scan_control.comboBox_scan_selection.count() == 1
assert scan_control.current_scan == "line_scan"
def test_allowed_scans_none_clears_filter(scan_control):
scan_control.allowed_scans = ["line_scan"]
assert scan_control.comboBox_scan_selection.count() == 1
scan_control.allowed_scans = None
assert scan_control.allowed_scans is None
assert scan_control.config.allowed_scans is None
assert scan_control.comboBox_scan_selection.count() == 2
def test_allowed_scans_override_support_filter(scan_control):
scan_control.allowed_scans = ["not_supported_scan_class", "line_scan"]
items = [
scan_control.comboBox_scan_selection.itemText(i)
for i in range(scan_control.comboBox_scan_selection.count())
]
assert items == ["not_supported_scan_class", "line_scan"]
def test_filter_change_saves_current_scan_parameters(scan_control):
assert scan_control.current_scan == "line_scan"
scan_control.allowed_scans = ["grid_scan"]
assert scan_control.current_scan == "grid_scan"
assert "line_scan" in scan_control.config.scans
def test_empty_allowed_scans_disable_scan_info_and_run(scan_control):
scan_control.allowed_scans = []
assert scan_control.comboBox_scan_selection.count() == 0
assert scan_control.comboBox_scan_selection.toolTip() == ""
assert not scan_control.scan_info_button.isEnabled()
assert not scan_control.button_run_scan.isEnabled()
# run_scan must not raise even if triggered without a selected scan
scan_control.run_scan()
def test_configured_allowed_scans_are_preserved(qtbot, mocked_client):
mocked_client.connector.set_and_publish(
MessageEndpoints.available_scans(), available_scans_message
)
config = ScanControlConfig(widget_class="ScanControl", allowed_scans=["grid_scan"])
widget = ScanControl(client=mocked_client, config=config)
qtbot.addWidget(widget)
assert widget.allowed_scans == ["grid_scan"]
assert widget.comboBox_scan_selection.count() == 1
assert widget.comboBox_scan_selection.currentText() == "grid_scan"
def test_scan_selector_settings_dialog_applies_checked_scans(scan_control, monkeypatch, qtbot):
def select_line_scan(dialog):
labels = [dialog.checkbox_for_scan(name).text() for name in ("line_scan", "grid_scan")]
assert labels == ["line_scan", "grid_scan"]
checkbox = dialog.checkbox_for_scan("grid_scan")
assert isinstance(checkbox, QCheckBox)
checkbox.setChecked(False)
return QDialog.DialogCode.Accepted
monkeypatch.setattr(ScanSelectionDialog, "exec", select_line_scan)
qtbot.mouseClick(scan_control.scan_selector_settings_button, Qt.MouseButton.LeftButton)
assert scan_control.allowed_scans == ["line_scan"]
assert scan_control.comboBox_scan_selection.count() == 1
assert scan_control.comboBox_scan_selection.currentText() == "line_scan"
def test_scan_selector_settings_dialog_all_checked_clears_filter(scan_control, monkeypatch, qtbot):
scan_control.allowed_scans = ["line_scan"]
def check_everything(dialog):
dialog.checkbox_for_scan("grid_scan").setChecked(True)
return QDialog.DialogCode.Accepted
monkeypatch.setattr(ScanSelectionDialog, "exec", check_everything)
qtbot.mouseClick(scan_control.scan_selector_settings_button, Qt.MouseButton.LeftButton)
assert scan_control.allowed_scans is None
assert scan_control.comboBox_scan_selection.count() == 2
def test_scan_selector_settings_dialog_is_released_after_use(scan_control, monkeypatch, qtbot):
monkeypatch.setattr(ScanSelectionDialog, "exec", lambda dialog: QDialog.DialogCode.Rejected)
with patch.object(ScanSelectionDialog, "deleteLater") as delete_later:
qtbot.mouseClick(scan_control.scan_selector_settings_button, Qt.MouseButton.LeftButton)
delete_later.assert_called_once()
def test_scan_selector_dialog_whole_row_click_toggles_checkbox(qtbot):
dialog = ScanSelectionDialog(
scan_names=["line_scan", "grid_scan"], selected_scans=["line_scan"]
)
qtbot.addWidget(dialog)
dialog.show()
qtbot.waitExposed(dialog)
checkbox = dialog.checkbox_for_scan("line_scan")
assert isinstance(checkbox, QCheckBox)
assert checkbox.isChecked()
indicator_width = checkbox.style().pixelMetric(
QStyle.PixelMetric.PM_IndicatorWidth, widget=checkbox
)
label_right = indicator_width + 8 + checkbox.fontMetrics().horizontalAdvance(checkbox.text())
empty_row_x = checkbox.rect().right() - 8
assert empty_row_x > label_right
qtbot.mouseClick(
checkbox, Qt.MouseButton.LeftButton, pos=QPoint(empty_row_x, checkbox.rect().center().y())
)
assert not checkbox.isChecked()
assert dialog.selected_scans() == []
def test_scan_selector_dialog_info_button_opens_docs_without_toggling(qtbot):
dialog = ScanSelectionDialog(
scan_names=["line_scan"],
selected_scans=["line_scan"],
scan_docs={"line_scan": available_scans_message.resource["line_scan"]["doc"]},
)
qtbot.addWidget(dialog)
dialog.setModal(True)
dialog.show()
qtbot.waitExposed(dialog)
checkbox = dialog.checkbox_for_scan("line_scan")
info_button = dialog.info_button_for_scan("line_scan")
qtbot.mouseClick(info_button, Qt.MouseButton.LeftButton)
assert checkbox.isChecked()
assert dialog._scan_info_dialog.parent() is dialog
assert dialog._scan_info_dialog.isVisible()
assert dialog._scan_info_dialog.windowTitle() == "Scan information: line_scan"
assert "Arguments" in dialog._scan_info_dialog.text_browser.toPlainText()
def test_scan_selector_settings_properties_are_profile_safe(scan_control):
exported = scan_control.export_settings()
# "No filter" survives the round trip as None so that future scans keep appearing.
assert exported["allowed_scans"] is None
assert exported["hide_scan_selector_settings_button"] is False
scan_control.load_settings(
{"allowed_scans": ["grid_scan"], "hide_scan_selector_settings_button": True}
)
assert scan_control.allowed_scans == ["grid_scan"]
assert scan_control.comboBox_scan_selection.currentText() == "grid_scan"
assert scan_control.hide_scan_selector_settings_button is True
assert scan_control.scan_selector_settings_button.isHidden()
scan_control.load_settings({"allowed_scans": None})
assert scan_control.allowed_scans is None
assert scan_control.comboBox_scan_selection.count() == 2
def test_scan_control_uses_gui_visibility_and_signature(qtbot, mocked_client):
scan_info = {
"class": "AnnotatedScan",
+122
View File
@@ -0,0 +1,122 @@
from bec_widgets.widgets.control.scan_control.scan_docstring import (
render_scan_docstring_html,
render_scan_tooltip_html,
)
def test_render_scan_docstring_html_formats_google_sections():
docstring = """Run a line scan over one motor.
The motor is moved through evenly spaced positions.
Args:
device (DeviceBase | str): Device to move.
start (float): Initial position.
Expressed in the device's configured engineering units.
Constraint: must be below the stop position.
Returns:
ScanReport: Handle for the submitted scan.
Raises:
ValueError: If the requested range is invalid.
Examples:
>>> scans.line_scan(samx, -1, 1, steps=11)
"""
rendered = render_scan_docstring_html("line_scan", docstring)
assert "<h1>line_scan</h1>" in rendered
assert "<h2>Arguments</h2>" in rendered
assert "<code>device</code>" in rendered
assert "DeviceBase | str" in rendered
assert "configured engineering units" in rendered
assert "Constraint: must be below" in rendered
assert "<code>Constraint</code>" not in rendered
assert "<h2>Returns</h2>" in rendered
assert "<h2>Raises</h2>" in rendered
assert "<h2>Examples</h2>" in rendered
assert "<pre>&gt;&gt;&gt; scans.line_scan" in rendered
def test_render_scan_tooltip_html_is_compact_and_informative():
docstring = """Run a line scan.
Args:
device (DeviceBase | str): Device to move.
start (float): Initial position.
Examples:
>>> scans.line_scan(samx, 0, 1)
"""
rendered = render_scan_tooltip_html("line_scan", docstring)
assert "<b>line_scan</b>" in rendered
assert "Run a line scan." in rendered
assert "device: DeviceBase | str" in rendered
assert "start: float" in rendered
assert "Examples" not in rendered
assert "full documentation" in rendered
def test_scan_docstring_renderers_escape_untrusted_content():
docstring = "Use <script>alert('x')</script> safely."
full = render_scan_docstring_html("<scan>", docstring)
tooltip = render_scan_tooltip_html("<scan>", docstring)
for rendered in (full, tooltip):
assert "&lt;scan&gt;" in rendered
assert "&lt;script&gt;" in rendered
assert "<script>" not in rendered
def test_trailing_prose_after_args_is_not_folded_into_fields():
docstring = (
"Run a scan.\n\n"
"Args:\n"
" device (DeviceBase): Device to move.\n\n"
"The scan aborts when limits are exceeded."
)
rendered = render_scan_docstring_html("scan", docstring)
assert "<p>The scan aborts when limits are exceeded.</p>" in rendered
assert "Device to move. The scan aborts" not in rendered
def test_unindented_colon_prose_is_not_parsed_as_field():
docstring = (
"Run a scan.\n\n"
"Args:\n"
" device (DeviceBase): Device to move.\n\n"
"Limit handling: the scan aborts."
)
rendered = render_scan_docstring_html("scan", docstring)
tooltip = render_scan_tooltip_html("scan", docstring)
assert "<code>Limit handling</code>" not in rendered
assert "<p>Limit handling: the scan aborts.</p>" in rendered
assert "Limit handling" not in tooltip
def test_typed_field_type_is_not_greedy():
docstring = "Run a scan.\n\nArgs:\n steps (int): Number of steps (per axis): must be > 0"
rendered = render_scan_docstring_html("scan", docstring)
tooltip = render_scan_tooltip_html("scan", docstring)
assert "<small>int</small>" in rendered
assert "Number of steps (per axis): must be &gt; 0" in rendered
assert "steps: int" in tooltip
def test_scan_docstring_renderers_handle_plain_and_missing_docs():
plain = render_scan_docstring_html("count", "Count detector readings.")
missing = render_scan_tooltip_html("count", None)
assert "<p>Count detector readings.</p>" in plain
assert "No documentation is available for this scan." in missing