mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-08-10 04:00:36 +02:00
feat(scan_control): add scan filter and docstring info dialogs
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>>> 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 "<scan>" in rendered
|
||||
assert "<script>" 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 > 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
|
||||
Reference in New Issue
Block a user