From 0a9b003bbfd418d34fd9e46d68e807a9d2bfc928 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 16 Oct 2026 14:46:12 +0000 Subject: [PATCH] refactor(scan_metadata): build the scan metadata form on PydanticWidgetForm --- .../forms_from_types/entry_list_editor.py | 297 ++++++++++++ .../pydantic_model_info_adapter.py | 20 +- .../forms_from_types/pydantic_widget_form.py | 310 ++++++++++-- .../editors/scan_metadata/scan_metadata.py | 236 +++++++-- tests/end-2-end/test_with_plugins_e2e.py | 6 +- .../test_dict_backed_table_model.py | 17 - tests/unit_tests/test_generated_form_form.py | 122 ++++- tests/unit_tests/test_scan_control.py | 36 +- tests/unit_tests/test_scan_metadata.py | 449 +++++++++--------- 9 files changed, 1144 insertions(+), 349 deletions(-) create mode 100644 bec_widgets/utils/forms_from_types/entry_list_editor.py diff --git a/bec_widgets/utils/forms_from_types/entry_list_editor.py b/bec_widgets/utils/forms_from_types/entry_list_editor.py new file mode 100644 index 00000000..bb2720d4 --- /dev/null +++ b/bec_widgets/utils/forms_from_types/entry_list_editor.py @@ -0,0 +1,297 @@ +"""Row-based editor for lists of values or key/value pairs, used by generated forms.""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import Qt, Signal # type: ignore +from qtpy.QtWidgets import ( + QHBoxLayout, + QLabel, + QLineEdit, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + + +class _EntryRow(QWidget): + """One editable row: an optional key field, a value field and a remove button.""" + + def __init__( + self, parent: QWidget, *, key_value: bool, key_placeholder: str, value_placeholder: str + ) -> None: + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + self.key_edit: QLineEdit | None = None + if key_value: + self.key_edit = QLineEdit(self) + self.key_edit.setPlaceholderText(key_placeholder) + layout.addWidget(self.key_edit, 2) + self.value_edit = QLineEdit(self) + self.value_edit.setPlaceholderText(value_placeholder) + layout.addWidget(self.value_edit, 3) + self.remove_button = QToolButton(self) + self.remove_button.setAutoRaise(True) + self.remove_button.setIcon(material_icon("close", size=(16, 16), convert_to_pixmap=False)) + self.remove_button.setToolTip("Remove this entry") + self.remove_button.setAccessibleName("Remove entry") + layout.addWidget(self.remove_button, 0) + + def key(self) -> str: + """Return the stripped key, or an empty string in plain-value mode.""" + return self.key_edit.text().strip() if self.key_edit is not None else "" + + def value(self) -> str: + """Return the value text as typed.""" + return self.value_edit.text() + + def is_blank(self) -> bool: + """Return whether both the key and the value are empty.""" + return not self.key() and not self.value().strip() + + +class EntryListEditor(QWidget): + """Edit a list of strings or a mapping of string keys to string values, one row per entry. + + Blank rows are ignored when reading the value, so users can add a row and leave it empty + without making the surrounding form invalid. + + Attributes: + changed: Emitted whenever an entry is added, removed or edited. + """ + + changed = Signal() + + def __init__( # pylint: disable=too-many-arguments + self, + parent: QWidget | None = None, + *, + key_value: bool = True, + key_placeholder: str = "Key", + value_placeholder: str = "Value", + add_text: str = "Add entry", + empty_text: str = "", + ) -> None: + """Create the editor. + + Args: + parent: Optional parent widget. + key_value: Edit key/value pairs when ``True``, plain values when ``False``. + key_placeholder: Placeholder text of the key fields. + value_placeholder: Placeholder text of the value fields. + add_text: Text of the button that appends a row. + empty_text: Muted hint shown while there are no rows; hidden when empty. + """ + super().__init__(parent) + self._key_value = key_value + self._key_placeholder = key_placeholder + self._value_placeholder = value_placeholder + self._rows: list[_EntryRow] = [] + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + self._rows_layout = QVBoxLayout() + self._rows_layout.setContentsMargins(0, 0, 0, 0) + self._rows_layout.setSpacing(6) + layout.addLayout(self._rows_layout) + + self._empty_label = QLabel(empty_text, self) + self._empty_label.setWordWrap(True) + self._empty_label.setEnabled(False) # muted text in every theme + self._empty_label.setVisible(bool(empty_text)) + layout.addWidget(self._empty_label) + + self.add_button = QToolButton(self) + self.add_button.setText(add_text) + self.add_button.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False)) + self.add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.add_button.setAutoRaise(True) + self.add_button.clicked.connect(self._add_and_focus) + layout.addWidget(self.add_button, 0, Qt.AlignmentFlag.AlignLeft) + + @property + def key_value(self) -> bool: + """Return whether rows edit key/value pairs (``True``) or plain values (``False``).""" + return self._key_value + + def rows(self) -> list[_EntryRow]: + """Return the row widgets, in display order.""" + return list(self._rows) + + def row_count(self) -> int: + """Return the number of rows, blank ones included.""" + return len(self._rows) + + def add_entry(self, key: str = "", value: str = "") -> _EntryRow: + """Append a row. + + Args: + key: Initial key; ignored in plain-value mode. + value: Initial value. + + Returns: + The new row widget. + """ + row = _EntryRow( + self, + key_value=self._key_value, + key_placeholder=self._key_placeholder, + value_placeholder=self._value_placeholder, + ) + if row.key_edit is not None: + row.key_edit.setText(str(key)) + row.key_edit.textChanged.connect(self._emit_changed) + row.value_edit.setText("" if value is None else str(value)) + row.value_edit.textChanged.connect(self._emit_changed) + row.remove_button.clicked.connect(lambda _=False, r=row: self._remove_row(r)) + self._rows.append(row) + self._rows_layout.addWidget(row) + self._update_empty_state() + self.changed.emit() + return row + + def remove_entry(self, index: int) -> None: + """Remove the row at ``index``. + + Args: + index: Row index in display order. + """ + self._remove_row(self._rows[index]) + + def clear(self) -> None: + """Remove all rows.""" + for row in list(self._rows): + self._remove_row(row, notify=False) + self.changed.emit() + + def entries(self) -> list[tuple[str, str]]: + """Return the non-blank rows as ``(key, value)`` tuples; keys are empty in value mode.""" + return [(row.key(), row.value()) for row in self._rows if not row.is_blank()] + + def value(self) -> dict[str, str] | list[str]: + """Return the content: a dict in key/value mode, a list of values otherwise.""" + if self._key_value: + return dict(self.entries()) + return [value for _key, value in self.entries()] + + def set_value(self, value) -> None: + """Replace all rows with ``value``. + + Args: + value: A mapping in key/value mode, an iterable of values otherwise. ``None`` + clears the editor. + """ + for row in list(self._rows): + self._remove_row(row, notify=False) + if value: + items = value.items() if self._key_value else (("", v) for v in value) + for key, item_value in items: + self.add_entry(key, item_value) + self._update_empty_state() + self.changed.emit() + + def set_row_error(self, row: _EntryRow, message: str | None) -> None: + """Mark a row's key field (or value field in value mode) as invalid. + + Args: + row: Row widget returned by :meth:`rows`. + message: Error text shown as tooltip, or ``None`` to clear the error. + """ + target = row.key_edit if row.key_edit is not None else row.value_edit + target.setProperty("state", "error" if message else "") + target.setToolTip(message or "") + target.style().unpolish(target) + target.style().polish(target) + + def _add_and_focus(self, *_args) -> None: + row = self.add_entry() + (row.key_edit or row.value_edit).setFocus() + + def _remove_row(self, row: _EntryRow, notify: bool = True) -> None: + self._rows.remove(row) + self._rows_layout.removeWidget(row) + row.setParent(None) + row.deleteLater() + self._update_empty_state() + if notify: + self.changed.emit() + + def _update_empty_state(self) -> None: + self._empty_label.setVisible(bool(self._empty_label.text()) and not self._rows) + + def _emit_changed(self, *_args) -> None: + self.changed.emit() + + +class ExtraFieldsSection(QWidget): + """Titled key/value editor for fields that are not part of the model. + + Used by ``PydanticWidgetForm`` for models that accept extra fields + (``model_config = ConfigDict(extra="allow")``). + """ + + def __init__(self, parent: QWidget | None = None, *, title: str, empty_text: str) -> None: + """Create the section. + + Args: + parent: Optional parent widget. + title: Section heading. + empty_text: Hint shown while there are no extra fields. + """ + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 6, 0, 0) + layout.setSpacing(4) + self.title_label = QLabel(title, self) + font = self.title_label.font() + font.setBold(True) + self.title_label.setFont(font) + layout.addWidget(self.title_label) + self.editor = EntryListEditor( + self, + key_value=True, + key_placeholder="Key", + value_placeholder="Value", + add_text="Add field", + empty_text=empty_text, + ) + layout.addWidget(self.editor) + + def row_errors(self, reserved_keys: set[str]) -> dict[int, str]: + """Return error messages of invalid rows, keyed by row index. + + Args: + reserved_keys: Keys that are taken by the form's own fields. + + Returns: + Messages for rows with a value but no key, a reserved key or a repeated key. + """ + errors: dict[int, str] = {} + seen: set[str] = set() + for index, row in enumerate(self.editor.rows()): + if row.is_blank(): + continue + key = row.key() + if not key: + errors[index] = "Enter a key for the value." + elif key in reserved_keys: + errors[index] = f"'{key}' is already a field of this form." + elif key in seen: + errors[index] = f"Key '{key}' is used more than once." + seen.add(key) + return errors + + def show_row_errors(self, reserved_keys: set[str]) -> None: + """Highlight the invalid rows and clear the highlight of valid ones. + + Args: + reserved_keys: Keys that are taken by the form's own fields. + """ + errors = self.row_errors(reserved_keys) + for index, row in enumerate(self.editor.rows()): + self.editor.set_row_error(row, errors.get(index)) diff --git a/bec_widgets/utils/forms_from_types/pydantic_model_info_adapter.py b/bec_widgets/utils/forms_from_types/pydantic_model_info_adapter.py index f998abe9..f0a31792 100644 --- a/bec_widgets/utils/forms_from_types/pydantic_model_info_adapter.py +++ b/bec_widgets/utils/forms_from_types/pydantic_model_info_adapter.py @@ -10,6 +10,20 @@ from pydantic_core import PydanticUndefined from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"} +# Annotated constraints that map onto form metadata keys, e.g. ``decimal_places`` -> ``precision`` +_CONSTRAINT_KEYS = {key: key for key in NUMERIC_BOUND_KEYS} | { + "decimal_places": "precision", + "max_length": "max_length", +} + + +def _constraint_metadata(entry: object) -> dict[str, Any]: + """Return the form metadata carried by one ``FieldInfo.metadata`` constraint entry.""" + return { + target: getattr(entry, key) + for key, target in _CONSTRAINT_KEYS.items() + if getattr(entry, key, None) is not None + } def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]: @@ -21,10 +35,8 @@ def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]] if isinstance(entry, ScanArgument): metadata.update(entry.model_dump(exclude_none=True)) continue - for key in NUMERIC_BOUND_KEYS: - value = getattr(entry, key, None) - if value is not None: - metadata.setdefault(key, value) + for key, value in _constraint_metadata(entry).items(): + metadata.setdefault(key, value) if isinstance(info.json_schema_extra, Mapping): metadata.update(dict(info.json_schema_extra)) diff --git a/bec_widgets/utils/forms_from_types/pydantic_widget_form.py b/bec_widgets/utils/forms_from_types/pydantic_widget_form.py index c3db17d5..b1c8907b 100644 --- a/bec_widgets/utils/forms_from_types/pydantic_widget_form.py +++ b/bec_widgets/utils/forms_from_types/pydantic_widget_form.py @@ -1,7 +1,8 @@ from __future__ import annotations -from types import NoneType -from typing import Any, Literal, get_args, get_origin +from decimal import Decimal +from types import NoneType, UnionType +from typing import Any, Literal, Union, get_args, get_origin from bec_lib.device import DeviceBase, Signal from pydantic import BaseModel, ValidationError @@ -14,11 +15,14 @@ from qtpy.QtWidgets import ( QDoubleSpinBox, QFormLayout, QHBoxLayout, + QLabel, QLineEdit, QSpinBox, QWidget, ) +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.forms_from_types.entry_list_editor import EntryListEditor, ExtraFieldsSection from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import ( NUMERIC_BOUND_KEYS, pydantic_model_input_configs, @@ -34,6 +38,17 @@ from bec_widgets.widgets.control.device_input.device_combobox.device_combobox im from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox +#: Key of :meth:`PydanticWidgetForm.validation_errors` entries about the extra fields editor. +EXTRA_FIELDS_KEY = "__extra_fields__" + + +def _value_annotation(annotation: Any) -> Any: + """Return the annotation without ``None`` for ``X | None``; unchanged otherwise.""" + non_none_args = tuple(arg for arg in get_args(annotation) if arg is not NoneType) + if NoneType in get_args(annotation) and len(non_none_args) == 1: + return non_none_args[0] + return annotation + class OptionalValueWidget(QWidget): """Wrap a value widget with an enable checkbox for optional Pydantic fields. @@ -116,6 +131,7 @@ class OptionalValueWidget(QWidget): self.value_changed.emit(self.value()) +# pylint: disable-next=too-many-instance-attributes,too-many-public-methods class PydanticWidgetForm(QWidget): """Generate a Qt form from a Pydantic model. @@ -132,7 +148,7 @@ class PydanticWidgetForm(QWidget): changed = QtSignal() validity_changed = QtSignal(bool) - def __init__( + def __init__( # pylint: disable=too-many-arguments self, model: type[BaseModel], parent: QWidget | None = None, @@ -140,6 +156,12 @@ class PydanticWidgetForm(QWidget): data: BaseModel | dict[str, Any] | None = None, read_only_fields: set[str] | None = None, client=None, + allow_extra_fields: bool = False, + extra_fields_title: str = "Additional fields", + extra_fields_hint: str = "", + mark_required: bool = False, + empty_text_as_missing: bool = False, + show_field_errors: bool = False, ) -> None: """Create a generated form for a Pydantic model. @@ -150,6 +172,17 @@ class PydanticWidgetForm(QWidget): read_only_fields: Field names that should be displayed but not editable. client: Optional BEC client passed to domain-specific widgets such as device and signal combo boxes. + allow_extra_fields: Show a key/value editor below the model fields for + entries that are not part of the model. Only useful for models with + ``extra="allow"``. + extra_fields_title: Heading of the extra fields editor. + extra_fields_hint: Hint shown in the extra fields editor while it is empty. + mark_required: Append ``*`` to the labels of fields without a default. + empty_text_as_missing: Treat an empty text field without a default as a + missing value, so validation reports it as required instead of + accepting an empty string. + show_field_errors: Show the validation message of an invalid field in a + line directly under its input when :meth:`validate` runs. """ super().__init__(parent=parent) self._model = model @@ -158,6 +191,17 @@ class PydanticWidgetForm(QWidget): self._widgets: dict[str, QWidget] = {} self._field_configs: dict[str, dict[str, Any]] = {} self._baseline: dict[str, Any] = {} + self._mark_required = mark_required + self._empty_text_as_missing = empty_text_as_missing + self._show_field_errors = show_field_errors + self._error_labels: dict[str, QLabel] = {} + self._shown_errors: dict[str, str] = {} + self._extra_section: ExtraFieldsSection | None = None + if allow_extra_fields: + self._extra_section = ExtraFieldsSection( + self, title=extra_fields_title, empty_text=extra_fields_hint + ) + self._extra_section.editor.changed.connect(self.changed) self._layout = QFormLayout() self._layout.setContentsMargins(0, 0, 0, 0) @@ -245,6 +289,31 @@ class PydanticWidgetForm(QWidget): widget for widget in self.input_widgets().values() if isinstance(widget, widget_type) ] + @property + def extra_fields_section(self) -> ExtraFieldsSection | None: + """Return the extra fields section, or ``None`` if extra fields are disabled.""" + return self._extra_section + + def field_label(self, name: str) -> str: + """Return the user-facing label of field ``name``, without the required marker.""" + return self._field_configs[name]["display_name"] + + def extra_data(self) -> dict[str, str]: + """Return the non-blank extra fields as a mapping; empty when they are disabled.""" + if self._extra_section is None: + return {} + return self._extra_section.editor.value() + + def set_extra_data(self, data: dict[str, Any] | None) -> None: + """Replace the entries of the extra fields editor. + + Args: + data: Mapping of extra keys to values; ``None`` clears the editor. + """ + if self._extra_section is None: + return + self._extra_section.editor.set_value(data) + def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None: """Replace the active model and rebuild the form. @@ -269,6 +338,10 @@ class PydanticWidgetForm(QWidget): data: Pydantic model instance or raw field-value mapping. """ values = data.model_dump() if isinstance(data, BaseModel) else dict(data) + if self._extra_section is not None: + self.set_extra_data( + {key: value for key, value in values.items() if key not in self._widgets} + ) self.set_partial_data(values) def set_partial_data(self, data: dict[str, Any]) -> None: @@ -291,9 +364,13 @@ class PydanticWidgetForm(QWidget): """Return current widget values without Pydantic validation. Returns: - Mapping of model field names to raw widget values. + Mapping of model field names to raw widget values, followed by the + entries of the extra fields editor that do not shadow a model field. """ - return {name: self._read_widget_value(name) for name in self._widgets} + data = {name: self._read_widget_value(name) for name in self._widgets} + for key, value in self.extra_data().items(): + data.setdefault(key, value) + return data def get_data(self) -> dict[str, Any]: """Return current data after Pydantic validation. @@ -318,21 +395,58 @@ class PydanticWidgetForm(QWidget): ValueError: If domain widget validation fails. """ self._validate_domain_widgets() - return self._model.model_validate(self.raw_data()) + self._validate_extra_fields() + return self._model.model_validate(self._validation_payload()) def validate(self) -> bool: - """Validate the current form values. + """Validate the current form values and highlight the fields that are invalid. Returns: ``True`` when current values validate successfully, otherwise ``False``. """ + errors = self.validation_errors() + self._show_errors(errors) + self.validity_changed.emit(not errors) + return not errors + + def validation_errors(self) -> dict[str, str]: + """Return a readable validation message for each invalid field. + + Missing values read ``Required - please fill in``. Every failing entry of a + field is listed on its own line, prefixed with its location inside the field, + e.g. ``[1]: Input should be a valid integer``. + + Returns: + Mapping of field names to error messages. Problems in the extra fields + editor are reported under :data:`EXTRA_FIELDS_KEY`; errors Pydantic + cannot attribute to a field are reported under ``""``. Empty when the + form is valid. + """ + found = dict(self._domain_widget_errors()) try: - self.get_data() - except (ValidationError, ValueError): - self.validity_changed.emit(False) - return False - self.validity_changed.emit(True) - return True + self._model.model_validate(self._validation_payload()) + except ValidationError as exc: + for name, message in self._describe_validation_error(exc).items(): + found.setdefault(name, message) + # Field order first, then problems outside the fields, then the extra fields. + errors = {name: found.pop(name) for name in self._widgets if name in found} + errors.update(found) + extra_errors = self._extra_field_errors() + if extra_errors: + errors[EXTRA_FIELDS_KEY] = next(iter(extra_errors.values())) + return errors + + def field_error_label(self, name: str) -> QLabel | None: + """Return the error line under field ``name``. + + Args: + name: Model field name. + + Returns: + The label, or ``None`` when the form was created without + ``show_field_errors``. + """ + return self._error_labels.get(name) def dirty_fields(self) -> set[str]: """Return fields whose raw values differ from the clean baseline. @@ -382,6 +496,9 @@ class PydanticWidgetForm(QWidget): def cleanup(self) -> None: """Close and schedule deletion of all generated field widgets.""" + if self._extra_section is not None and self._layout.indexOf(self._extra_section) >= 0: + # The extra fields section outlives model changes; take it out before clearing. + self._layout.takeRow(self._extra_section) while self._layout.rowCount(): row = self._layout.takeRow(0) for item in (row.labelItem, row.fieldItem): @@ -395,6 +512,8 @@ class PydanticWidgetForm(QWidget): widget.deleteLater() self._widgets.clear() self._field_configs.clear() + self._error_labels.clear() + self._shown_errors.clear() def closeEvent(self, event) -> None: # noqa: N802 self.cleanup() @@ -406,6 +525,8 @@ class PydanticWidgetForm(QWidget): info = self._model.model_fields[name] widget = self._create_widget(name, info) label_text = config["display_name"] + if self._mark_required and info.is_required(): + label_text = f"{label_text} *" self._layout.addRow(label_text, widget) label = self._layout.labelForField(widget) if label is not None: @@ -418,6 +539,11 @@ class PydanticWidgetForm(QWidget): self._set_widget_value(name, config["default"]) self._apply_field_metadata(name) self._connect_widget(widget) + if self._show_field_errors: + self._add_error_label(name) + + if self._extra_section is not None: + self._layout.addRow(self._extra_section) self._connect_device_signal_widgets() self._connect_reference_unit_widgets() @@ -425,14 +551,13 @@ class PydanticWidgetForm(QWidget): def _create_widget(self, name: str, info: FieldInfo) -> QWidget: annotation = info.annotation - args = get_args(annotation) - optional = NoneType in args - non_none_args = tuple(arg for arg in args if arg is not NoneType) - value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation + optional = NoneType in get_args(annotation) + value_annotation = _value_annotation(annotation) widget = self._create_value_widget(name, value_annotation) - numeric = value_annotation in (int, float) or any( - arg in (int, float) for arg in get_args(value_annotation) + numeric = value_annotation in (int, float, Decimal) or ( + get_origin(value_annotation) in (Union, UnionType) + and any(arg in (int, float, Decimal) for arg in get_args(value_annotation)) ) if optional and (numeric or value_annotation is bool): return OptionalValueWidget(widget, parent=self) @@ -459,8 +584,17 @@ class PydanticWidgetForm(QWidget): return DeviceComboBox(parent=self, client=self._client, arg_name=name) if get_origin(annotation) is Literal: widget = QComboBox(self) + if NoneType in get_args(self._model.model_fields[name].annotation): + widget.addItem("") # the empty entry stands for None widget.addItems([str(value) for value in get_args(annotation)]) return widget + if annotation is Decimal: + spin_box = QDoubleSpinBox(self) + spin_box.setRange(-1_000_000_000, 1_000_000_000) + return spin_box + container = get_origin(annotation) or annotation + if container in (dict, list, set, tuple): + return EntryListEditor(self, key_value=container is dict, add_text="Add entry") if annotation is bool: return QCheckBox(self) if annotation is int: @@ -487,10 +621,20 @@ class PydanticWidgetForm(QWidget): if input_widget is not field_widget: apply_unit_metadata(input_widget, config) + if isinstance(input_widget, QLineEdit): + if config.get("max_length") is not None: + input_widget.setMaxLength(int(config["max_length"])) + placeholder = config.get("placeholder") or config.get("description") + if placeholder: + input_widget.setPlaceholderText(str(placeholder)) + def _connect_widget(self, widget: QWidget) -> None: if isinstance(widget, OptionalValueWidget): widget.value_changed.connect(lambda _value: self.changed.emit()) return + if isinstance(widget, EntryListEditor): + widget.changed.connect(self.changed) + return WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit()) def _connect_device_signal_widgets(self) -> None: @@ -551,28 +695,131 @@ class PydanticWidgetForm(QWidget): self._apply_reference_units(source_name, None) def _validate_domain_widgets(self) -> None: - for widget in self._widgets.values(): + for message in self._domain_widget_errors().values(): + raise ValueError(message) + + def _domain_widget_errors(self) -> dict[str, str]: + errors: dict[str, str] = {} + for name, widget in self._widgets.items(): if isinstance(widget, DeviceComboBox): device = widget.currentText().strip() if not device: - raise ValueError("Device is required.") - if not widget.is_valid_input: - raise ValueError(f"Device '{device}' is not available.") + errors[name] = "Device is required." + elif not widget.is_valid_input: + errors[name] = f"Device '{device}' is not available." if isinstance(widget, SignalComboBox): signal = widget.get_signal_name().strip() if signal and not widget.is_valid_input: - raise ValueError(f"Signal '{signal}' is not available.") + errors[name] = f"Signal '{signal}' is not available." + return errors + + def _validate_extra_fields(self) -> None: + for message in self._extra_field_errors().values(): + raise ValueError(message) + + def _extra_field_errors(self) -> dict[int, str]: + if self._extra_section is None: + return {} + return self._extra_section.row_errors(set(self._widgets)) + + def _validation_payload(self) -> dict[str, Any]: + data = self.raw_data() + if self._empty_text_as_missing: + for name, widget in self._widgets.items(): + if ( + isinstance(widget, QLineEdit) + and data.get(name) in ("", None) + and self._model.model_fields[name].is_required() + ): + data.pop(name, None) + return data + + def _add_error_label(self, name: str) -> None: + """Add the hidden error line in the field column of the row under field ``name``.""" + error = QLabel(self) + error.setWordWrap(True) + error.setTextFormat(Qt.TextFormat.PlainText) + error.setVisible(False) + self._layout.setWidget(self._layout.rowCount(), QFormLayout.ItemRole.FieldRole, error) + self._error_labels[name] = error + + def _describe_validation_error(self, error: ValidationError) -> dict[str, str]: + """Group Pydantic's errors by top-level field, without error codes and links.""" + problems: dict[str, list[str]] = {} + for err in error.errors(): + loc = tuple(err.get("loc") or ()) + name = str(loc[0]) if loc else "" + if err["type"] == "missing" or err.get("input") is None: + message = "Required - please fill in" + else: + message = err["msg"] + path = self._error_path(name, loc[1:]) + line = f"{path}: {message}" if path else message + lines = problems.setdefault(name, []) + if line not in lines: + lines.append(line) + return {name: "\n".join(lines) for name, lines in problems.items()} + + def _error_path(self, name: str, sub_loc: tuple) -> str: + """Readable location inside a field, e.g. ``[1]``, ``key`` or ``inner[0].name``. + + For fields with several possible types (``int | float``), Pydantic reports one + error per type, tagged with the type name; that tag is not a location and is + dropped. + """ + field = self._model.model_fields.get(name) + annotation = _value_annotation(field.annotation) if field is not None else None + if get_origin(annotation) in (Union, UnionType) and sub_loc and isinstance(sub_loc[0], str): + sub_loc = sub_loc[1:] + path = "" + for part in sub_loc: + path += f"[{part}]" if isinstance(part, int) else f".{part}" + return path.lstrip(".") + + def _show_errors(self, errors: dict[str, str]) -> None: + """Highlight invalid inputs and their labels; clear fields that became valid.""" + error_color = get_accent_colors().emergency.name() + for name in set(self._shown_errors) | set(errors): + if name not in self._widgets: + continue + message = errors.get(name) + if self._shown_errors.get(name) == message: + continue + widget = self.input_widget(name) + widget.setProperty("state", "error" if message else "") + widget.style().unpolish(widget) + widget.style().polish(widget) + label = self._layout.labelForField(self._widgets[name]) + if label is not None: + # Themes do not style QLabel[state="error"], so colour the label directly. + label.setStyleSheet(f"color: {error_color};" if message else "") + error_label = self._error_labels.get(name) + if error_label is not None: + error_label.setStyleSheet(f"color: {error_color};") + error_label.setText(message or "") + error_label.setVisible(message is not None) + if self._extra_section is not None: + self._extra_section.show_row_errors(set(self._widgets)) + self._shown_errors = {name: msg for name, msg in errors.items() if name in self._widgets} def _read_widget_value(self, name: str) -> Any: widget = self._widgets[name] info = self._model.model_fields[name] if isinstance(widget, OptionalValueWidget): return widget.value() + if isinstance(widget, EntryListEditor): + return widget.value() + if isinstance(widget, QDoubleSpinBox) and _value_annotation(info.annotation) is Decimal: + return Decimal(f"{widget.value():.{widget.decimals()}f}") if isinstance(widget, QLineEdit): value = WidgetIO.get_value(widget) return None if NoneType in get_args(info.annotation) and value == "" else value - if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal: - return WidgetIO.get_value(widget, as_string=True) + if ( + isinstance(widget, QComboBox) + and get_origin(_value_annotation(info.annotation)) is Literal + ): + value = WidgetIO.get_value(widget, as_string=True) + return None if NoneType in get_args(info.annotation) and value == "" else value return WidgetIO.get_value(widget) def _set_widget_value(self, name: str, value: Any) -> None: @@ -580,6 +827,11 @@ class PydanticWidgetForm(QWidget): if isinstance(widget, OptionalValueWidget): widget.set_value(value) return + if isinstance(widget, EntryListEditor): + widget.set_value(value) + return + if isinstance(value, Decimal): + value = float(value) if value is None: if isinstance(widget, QLineEdit): value = "" @@ -587,6 +839,10 @@ class PydanticWidgetForm(QWidget): value = False elif isinstance(widget, (QSpinBox, QDoubleSpinBox)): value = 0 + elif isinstance(widget, QComboBox): + value = "" + elif isinstance(widget, QLineEdit) and not isinstance(value, str): + value = str(value) WidgetIO.set_value(widget, value) def _model_has_device_field(self) -> bool: @@ -614,7 +870,7 @@ if __name__ == "__main__": # pragma: no cover from bec_lib.scan_args import ScanArgument from pydantic import Field - from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout + from qtpy.QtWidgets import QApplication, QPushButton, QTabWidget, QTextEdit, QVBoxLayout from bec_widgets.utils.colors import apply_theme diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 6271c0fe..d620d61e 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -1,23 +1,54 @@ from __future__ import annotations from decimal import Decimal +from types import NoneType +from typing import Any from bec_lib.logger import bec_logger from bec_lib.metadata_schema import get_metadata_schema_for_scan from pydantic import Field -from qtpy.QtWidgets import QApplication, QComboBox, QHBoxLayout, QVBoxLayout, QWidget +from qtpy.QtCore import Qt, Signal # type: ignore +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QLabel, + QLineEdit, + QSizePolicy, + QVBoxLayout, + QWidget, +) +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.expandable_frame import ExpandableGroupFrame -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm -from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable +from bec_widgets.utils.forms_from_types.pydantic_widget_form import ( + EXTRA_FIELDS_KEY, + PydanticWidgetForm, +) logger = bec_logger.logger -class ScanMetadata(PydanticModelForm): +class ScanMetadata(BECWidget, QWidget): + """Form for the metadata of a scan, generated from the scan's pydantic metadata schema. + Uses the metadata schema registry supplied in the plugin repo to find the pydantic model + associated with the scan, falling back to ``BasicScanMetadata``. Free key/value pairs that + are not part of the schema can be added in the "Additional metadata" section. + + Attributes: + form_data_updated: Emitted with the validated metadata whenever the form is valid. + form_data_cleared: Emitted with ``None`` whenever the form is invalid. + validity_proc: Emitted with the validity of the form after every validation. + """ + + PLUGIN = True RPC = False + ICON_NAME = "list_alt" + + form_data_updated = Signal(dict) + form_data_cleared = Signal(NoneType) + validity_proc = Signal(bool) def __init__( self, @@ -27,45 +58,78 @@ class ScanMetadata(PydanticModelForm): initial_extras: list[list[str]] | None = None, **kwargs, ): - """Dynamically generates a form for inclusion of metadata for a scan. Uses the - metadata schema registry supplied in the plugin repo to find pydantic models - associated with the scan type. Sets limits for numerical values if specified. + """Create the metadata form. Args: scan_name (str): The scan for which to generate a metadata form - Initial_extras (list[list[str]]): Initial data with which to populate the additional - metadata table - inner lists should be key-value pairs + initial_extras (list[list[str]]): Initial key-value pairs of the additional + metadata section. """ - - # self.populate() gets called in super().__init__ - # so make sure self._additional_metadata exists - self._additional_md_box = ExpandableGroupFrame( - parent, "Additional metadata", expanded=False - ) - self._additional_md_box_layout = QHBoxLayout() - self._additional_md_box.set_layout(self._additional_md_box_layout) - - self._additional_metadata = DictBackedTable(parent, initial_extras or []) + super().__init__(parent=parent, client=client, **kwargs) self._scan_name = scan_name or "" self._md_schema = get_metadata_schema_for_scan(self._scan_name) - self._additional_metadata.data_changed.connect(self.validate_form) self._hide_optional_metadata = False - super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(4) - self._layout.setContentsMargins(0, 0, 0, 0) - self._form_grid_container.layout().setContentsMargins(0, 0, 0, 0) - self._layout.addWidget(self._additional_md_box) - self._additional_md_box_layout.addWidget(self._additional_metadata) + self._form = PydanticWidgetForm( + self._md_schema, + parent=self, + client=client, + allow_extra_fields=True, + extra_fields_title="Additional metadata", + extra_fields_hint="Store extra information with the scan as key-value pairs.", + mark_required=True, + empty_text_as_missing=True, + show_field_errors=True, + ) + self._form.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + main_layout.addWidget(self._form) + self._validity_message = QLabel("Not yet validated", self) + self._validity_message.setWordWrap(True) + self._validity_message.setTextFormat(Qt.TextFormat.PlainText) + main_layout.addWidget(self._validity_message) + + self._form.set_extra_data({str(k): v for k, v in (initial_extras or [])}) + self._update_scan_name_placeholder() + self._form.changed.connect(self.validate_form) + + @property + def form(self) -> PydanticWidgetForm: + """The generated form holding the schema fields and the additional metadata.""" + return self._form @SafeSlot(str) def update_with_new_scan(self, scan_name: str): + """Switch to the metadata schema of ``scan_name`` and validate the form. + + Values of fields shared by both schemas and the additional metadata are kept. + + Args: + scan_name (str): Name of the newly selected scan. + """ self.set_schema_from_scan(scan_name) self.validate_form() + def set_schema_from_scan(self, scan_name: str | None): + """Rebuild the form for the metadata schema of ``scan_name``. + + Args: + scan_name (str | None): Name of the scan; ``None`` selects the default schema. + """ + self._scan_name = scan_name or "" + schema = get_metadata_schema_for_scan(self._scan_name) + if schema is not self._md_schema: + self._md_schema = schema + self._form.set_model(schema) + self._update_scan_name_placeholder() + @SafeProperty(bool) def hide_optional_metadata(self): # type: ignore - """Property to hide the optional metadata table.""" + """Property to hide the additional metadata section.""" return self._hide_optional_metadata @hide_optional_metadata.setter @@ -73,37 +137,109 @@ class ScanMetadata(PydanticModelForm): """Setter for the hide_optional_metadata property. Args: - hide(bool): Hide or show the optional metadata table. + hide(bool): Hide or show the additional metadata section. """ self._hide_optional_metadata = hide - self._additional_md_box.setVisible(not hide) + self._form.extra_fields_section.setVisible(not hide) - def get_form_data(self): - """Get the entered metadata as a dict""" - form_data = self._additional_metadata.dump_dict() | self._dict_from_grid() + def set_field_values(self, values: dict[str, Any]): + """Fill schema fields; keys that are not fields of the current schema are ignored. - # If scan_name is empty, set it to the current scan - if "scan_name" in form_data and not form_data["scan_name"]: - form_data["scan_name"] = self._scan_name + Args: + values (dict): Mapping of field names to values. + """ + self._form.set_partial_data(values) - return form_data + def set_extra_metadata(self, extras: dict[str, Any] | None): + """Replace the additional metadata key-value pairs. - def populate(self): - self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys())) - super().populate() + Args: + extras (dict | None): The new key-value pairs. + """ + self._form.set_extra_data(extras) - # Set scan_name field to current scan if it exists and is empty - if "scan_name" not in self.widget_dict: - return - scan_name_widget = self.widget_dict["scan_name"] - if not hasattr(scan_name_widget, "getValue") or scan_name_widget.getValue(): - return - if hasattr(scan_name_widget, "setValue"): - scan_name_widget.setValue(self._scan_name) + def get_form_data(self) -> dict[str, Any]: + """Get the entered metadata as a dict, without validating it. - def set_schema_from_scan(self, scan_name: str | None): - self._scan_name = scan_name or "" - self.set_schema(get_metadata_schema_for_scan(self._scan_name)) + An empty scan name is replaced by the name of the current scan. + """ + return self._finalize(self._form.raw_data()) + + @SafeSlot() + def validate_form(self, *_) -> bool: + """Validate the metadata against the schema and publish the result. + + Emits the validated metadata on ``form_data_updated`` or ``None`` on + ``form_data_cleared``. Each invalid field shows its problem under its input, + and a one-line summary under the form names the invalid fields; its tooltip + lists every problem. + + Returns: + bool: Whether the metadata is valid. + """ + valid = self._form.validate() + accents = get_accent_colors() + if valid: + self._set_validity(accents.success.name(), "No errors!", "") + self.form_data_updated.emit(self._finalize(self._form.get_data())) + else: + errors = self._form.validation_errors() + titles = [self._error_title(name) for name in errors] + if len(titles) == 1: + summary = f"Invalid field: {titles[0]}" + else: + summary = f"{len(titles)} invalid fields: {', '.join(titles)}" + details = "\n".join(self._messages(errors)) + self._set_validity(accents.emergency.name(), summary, details) + self.form_data_cleared.emit(None) + self.validity_proc.emit(valid) + return valid + + def validation_messages(self) -> list[str]: + """Return one readable line per problem, in form order. + + Returns: + list[str]: Lines such as ``"Sample Name: Required - please fill in"``. + """ + return self._messages(self._form.validation_errors()) + + def _messages(self, errors: dict[str, str]) -> list[str]: + return [ + f"{self._error_title(name)}: {line}" + for name, message in errors.items() + for line in message.split("\n") + ] + + def _error_title(self, name: str) -> str: + if name == EXTRA_FIELDS_KEY: + return "Additional metadata" + if name in self._form.widgets: + return self._form.field_label(name) + return name or "Form" + + def _set_validity(self, color: str, summary: str, details: str): + self._validity_message.setStyleSheet(f"color: {color};") + self._validity_message.setText(summary) + self._validity_message.setToolTip(details) + + def _finalize(self, data: dict[str, Any]) -> dict[str, Any]: + data = { + key: float(value) if isinstance(value, Decimal) else value + for key, value in data.items() + } + if "scan_name" in data and not data["scan_name"]: + data["scan_name"] = self._scan_name + return data + + def _update_scan_name_placeholder(self): + widget = self._form.widgets.get("scan_name") + if isinstance(widget, QLineEdit): + widget.setPlaceholderText(self._scan_name or "Name of the selected scan") + + def cleanup(self): + """Release the generated field widgets.""" + self._form.cleanup() + super().cleanup() if __name__ == "__main__": # pragma: no cover diff --git a/tests/end-2-end/test_with_plugins_e2e.py b/tests/end-2-end/test_with_plugins_e2e.py index d006c6d0..5865d637 100644 --- a/tests/end-2-end/test_with_plugins_e2e.py +++ b/tests/end-2-end/test_with_plugins_e2e.py @@ -7,7 +7,6 @@ try: except ImportError: pytest.skip(reason="Requires plugin repo!", allow_module_level=True) - from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.control.scan_control import ScanControl @@ -67,9 +66,10 @@ def test_scan_metadata_for_custom_scan( def do_test(): # Set the metadata - for field_name, widget in scan_control._metadata_form.widget_dict.items(): + form = scan_control._metadata_form.form + for field_name in list(form.widgets): if (value_to_set := md.pop(field_name, None)) is not None: - widget.setValue(value_to_set) + form.set_partial_data({field_name: value_to_set}) # all values should be used assert md == {} assert scan_control.button_run_scan.isEnabled() diff --git a/tests/unit_tests/test_dict_backed_table_model.py b/tests/unit_tests/test_dict_backed_table_model.py index 4087ab5f..6aefcc2d 100644 --- a/tests/unit_tests/test_dict_backed_table_model.py +++ b/tests/unit_tests/test_dict_backed_table_model.py @@ -16,7 +16,6 @@ from qtpy.QtTest import QAbstractItemModelTester from bec_widgets.tests.utils import create_widget from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable -from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata INITIAL_DATA = [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]] @@ -171,22 +170,6 @@ def test_tables_do_not_share_backing_data(qtbot): assert second._table_model.rowCount() == 0 -def test_scan_metadata_form_drops_deleted_extra_row(qtbot): - widget = create_widget(qtbot, ScanMetadata, initial_extras=[["k1", "v1"], ["k2", "v2"]]) - received: list[dict] = [] - widget.form_data_updated.connect(received.append) - table = widget._additional_metadata - - selection = table._table_view.selectionModel() - selection.select(table._table_model.index(0, 0), QItemSelectionModel.SelectionFlag.Select) - table.delete_selected_rows() - - assert table.dump_dict() == {"k2": "v2"} - assert received, "deleting a row must re-validate the form" - assert "k1" not in received[-1] - assert received[-1]["k2"] == "v2" - - def test_values_are_not_checked_against_keys(table): model = table._table_model table.update_disallowed_keys(["forbidden"]) diff --git a/tests/unit_tests/test_generated_form_form.py b/tests/unit_tests/test_generated_form_form.py index df65f437..cb23cb8c 100644 --- a/tests/unit_tests/test_generated_form_form.py +++ b/tests/unit_tests/test_generated_form_form.py @@ -1,16 +1,19 @@ from decimal import Decimal +from typing import Literal from unittest.mock import patch import pytest from bec_lib.device import Device, Signal from bec_lib.scan_args import ScanArgument -from pydantic import BaseModel, Field -from qtpy.QtWidgets import QCheckBox, QLabel, QLineEdit +from pydantic import BaseModel, ConfigDict, Field +from qtpy.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLabel, QLineEdit from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.utils.forms_from_types.entry_list_editor import EntryListEditor from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem from bec_widgets.utils.forms_from_types.pydantic_widget_form import ( + EXTRA_FIELDS_KEY, OptionalValueWidget, PydanticWidgetForm, ) @@ -326,3 +329,118 @@ def test_pydantic_widget_form_preserves_optional_bool_none(qtbot): assert form.raw_data()["enabled"] is True assert form.model_instance().enabled is True + + +class GeneratedExtraSchema(BaseModel): + model_config = ConfigDict(extra="allow") + + sample: str = Field(title="Sample", description="Sample identifier", max_length=8) + dose: Decimal = Field(Decimal("0.50"), decimal_places=2, ge=0) + tags: list[int] = Field(default_factory=list) + settings: dict[str, int] = Field(default_factory=dict) + mode: Literal["fast", "slow"] | None = None + + +def test_pydantic_widget_form_extra_fields_round_trip(qtbot): + form = PydanticWidgetForm(GeneratedExtraSchema, allow_extra_fields=True) + qtbot.addWidget(form) + form.set_data({"sample": "s1", "operator": "jan"}) + + assert form.extra_data() == {"operator": "jan"} + assert form.raw_data()["operator"] == "jan" + assert form.get_data()["operator"] == "jan" + + +def test_pydantic_widget_form_extra_fields_survive_model_change(qtbot): + class Other(BaseModel): + model_config = ConfigDict(extra="allow") + sample: str = "" + + form = PydanticWidgetForm(GeneratedExtraSchema, allow_extra_fields=True) + qtbot.addWidget(form) + form.set_data({"sample": "s1", "operator": "jan"}) + form.set_model(Other) + + assert form.layout().indexOf(form.extra_fields_section) >= 0 + assert form.raw_data() == {"sample": "s1", "operator": "jan"} + + +def test_pydantic_widget_form_reports_invalid_extra_keys(qtbot): + form = PydanticWidgetForm(GeneratedExtraSchema, allow_extra_fields=True) + qtbot.addWidget(form) + form.set_partial_data({"sample": "s1"}) + form.set_extra_data({"sample": "clash"}) + + assert form.validation_errors() == { + EXTRA_FIELDS_KEY: "'sample' is already a field of this form." + } + assert not form.validate() + with pytest.raises(ValueError, match="already a field"): + form.get_data() + row = form.extra_fields_section.editor.rows()[0] + assert row.key_edit.property("state") == "error" + + +def test_pydantic_widget_form_without_extra_fields_has_no_section(qtbot): + form = PydanticWidgetForm(GeneratedExtraSchema) + qtbot.addWidget(form) + assert form.extra_fields_section is None + assert form.extra_data() == {} + + +def test_pydantic_widget_form_decimal_list_dict_and_optional_literal(qtbot): + form = PydanticWidgetForm(GeneratedExtraSchema) + qtbot.addWidget(form) + + dose = form.input_widget("dose") + assert type(dose) is QDoubleSpinBox + assert dose.decimals() == 2 + assert form.raw_data()["dose"] == Decimal("0.50") + assert isinstance(form.input_widget("tags"), EntryListEditor) + assert not form.input_widget("tags").key_value + assert form.input_widget("settings").key_value + mode = form.input_widget("mode") + assert isinstance(mode, QComboBox) + assert [mode.itemText(i) for i in range(mode.count())] == ["", "fast", "slow"] + + form.set_partial_data( + {"sample": "s1", "tags": [1, 2], "settings": {"gain": 3}, "dose": Decimal("1.25")} + ) + data = form.get_data() + assert data["tags"] == [1, 2] + assert data["settings"] == {"gain": 3} + assert data["dose"] == Decimal("1.25") + assert data["mode"] is None + + mode.setCurrentText("slow") + assert form.get_data()["mode"] == "slow" + + +def test_pydantic_widget_form_text_constraints_and_placeholder(qtbot): + form = PydanticWidgetForm(GeneratedExtraSchema) + qtbot.addWidget(form) + sample = form.input_widget("sample") + assert sample.maxLength() == 8 + assert sample.placeholderText() == "Sample identifier" + + +def test_pydantic_widget_form_required_markers_and_empty_text(qtbot): + plain = PydanticWidgetForm(GeneratedExtraSchema) + strict = PydanticWidgetForm( + GeneratedExtraSchema, mark_required=True, empty_text_as_missing=True + ) + qtbot.addWidget(plain) + qtbot.addWidget(strict) + + # default behaviour is unchanged: an empty required text is an empty string + assert plain.layout().labelForField(plain.field_widget("sample")).text() == "Sample" + assert plain.validate() + + assert strict.layout().labelForField(strict.field_widget("sample")).text() == "Sample *" + assert strict.validation_errors() == {"sample": "Required - please fill in"} + assert not strict.validate() + assert strict.input_widget("sample").property("state") == "error" + + strict.input_widget("sample").setText("s1") + assert strict.validate() + assert strict.input_widget("sample").property("state") == "" diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 1fb9e6af..cde9fca2 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -8,11 +8,10 @@ import pytest from bec_lib.endpoints import MessageEndpoints from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage from bec_lib.scan_history import ScanHistory -from qtpy.QtCore import QModelIndex, QPoint, Qt -from qtpy.QtWidgets import QCheckBox, QComboBox, QDialog, QStyle +from qtpy.QtCore import QPoint, Qt +from qtpy.QtWidgets import QCheckBox, QComboBox, QDialog, QLineEdit, QStyle from bec_widgets.tests.client_mocks import mocked_client -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 @@ -1750,16 +1749,12 @@ def test_scan_metadata_is_updated_even_without_default_form_changes( assert scan_control._metadata_form._scan_name == "line_scan" scan_control.comboBox_scan_selection.setCurrentText("grid_scan") assert scan_control._metadata_form._scan_name == "grid_scan" - scan_control._metadata_form._additional_metadata._add_button.click() - qtbot.wait(100) - table_model = scan_control._metadata_form._additional_metadata._table_model - model_key = table_model.index(0, 0, QModelIndex()) - table_model.setData(model_key, "test key 1", Qt.EditRole) - model_value = model_key.siblingAtColumn(1) - table_model.setData(model_value, "test value 1", Qt.EditRole) - assert scan_control._metadata_form._additional_metadata.dump_dict() == { - "test key 1": "test value 1" - } + editor = scan_control._metadata_form.form.extra_fields_section.editor + editor.add_button.click() + row = editor.rows()[0] + row.key_edit.setText("test key 1") + row.value_edit.setText("test value 1") + assert editor.value() == {"test key 1": "test value 1"} assert scan_control._scan_metadata == { "comment": "", "sample_name": "", @@ -1772,22 +1767,19 @@ def test_scan_metadata_is_connected(scan_control): assert scan_control._metadata_form._scan_name == "line_scan" scan_control.comboBox_scan_selection.setCurrentText("grid_scan") assert scan_control._metadata_form._scan_name == "grid_scan" - sample_name = scan_control._metadata_form.widget_dict["sample_name"] - assert isinstance(sample_name, StrFormItem) - sample_name._main_widget.setText("Test Sample") + sample_name = scan_control._metadata_form.form.input_widget("sample_name") + assert isinstance(sample_name, QLineEdit) + sample_name.setText("Test Sample") - scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY - scan_control._metadata_form.validate_form() + scan_control._metadata_form.set_extra_metadata(dict(TEST_TABLE_ENTRY)) assert scan_control._scan_metadata == TEST_MD def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl): scan_control.comboBox_scan_selection.setCurrentText("grid_scan") - sample_name = scan_control._metadata_form.widget_dict["sample_name"] - sample_name._main_widget.setText("Test Sample") - scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY - scan_control._metadata_form.validate_form() + scan_control._metadata_form.form.input_widget("sample_name").setText("Test Sample") + scan_control._metadata_form.set_extra_metadata(dict(TEST_TABLE_ENTRY)) assert scan_control._scan_metadata == TEST_MD diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index d1ef776a..9c5869c8 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -1,22 +1,18 @@ from decimal import Decimal -from typing import Set import pytest from bec_lib.metadata_schema import BasicScanMetadata -from pydantic import BaseModel, Field, ValidationError +from pydantic import Field, ValidationError from pydantic.types import Json from qtpy.QtCore import QItemSelectionModel, QPoint, Qt +from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QFormLayout, QLineEdit, QSpinBox -from bec_widgets.utils.forms_from_types.items import ( - BoolFormItem, - DictFormItem, - DynamicFormItem, - FloatDecimalFormItem, - IntFormItem, - StrFormItem, -) +from bec_widgets.tests.utils import create_widget +from bec_widgets.utils.forms_from_types.entry_list_editor import EntryListEditor +from bec_widgets.utils.forms_from_types.pydantic_widget_form import OptionalValueWidget from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata +from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox # pylint: disable=no-member # pylint: disable=missing-function-docstring @@ -40,8 +36,27 @@ class ExampleSchema(BasicScanMetadata): unsupported_class: Json = Field(default=set()) +class TwoRequiredSchema(BasicScanMetadata): + sample_id: str = Field(title="Sample ID") + operator: str = Field(title="Operator") + + +TEST_VALUES = { + "sample_name": "test name", + "str_optional": None, + "str_required": "something", + "bool_optional": None, + "bool_required_nodefault": False, + "int_default": 21, + "int_nodefault_optional": -10, + "float_nodefault": 0.1, + "decimal_dp_limits_nodefault": 456.789, + "dict_default": {"test_dict": "values"}, + "unsupported_class": '["set", "item"]', +} + TEST_DICT = { - "scan_name": "", + "scan_name": "example_scan", "comment": "", "sample_name": "test name", "str_optional": None, @@ -59,255 +74,241 @@ TEST_DICT = { @pytest.fixture -def example_md(): - return ExampleSchema.model_validate(TEST_DICT) +def schema_registry(monkeypatch): + monkeypatch.setattr( + "bec_lib.metadata_schema._get_metadata_schema_registry", + lambda: {"example_scan": ExampleSchema, "two_required": TwoRequiredSchema}, + ) @pytest.fixture -def empty_metadata_widget(qtbot): - widget = ScanMetadata() - widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]] - qtbot.addWidget(widget) +def metadata_widget(qtbot, schema_registry): + widget = create_widget( + qtbot, + ScanMetadata, + scan_name="example_scan", + initial_extras=[["extra_field", "extra_data"]], + ) yield widget -@pytest.fixture -def metadata_widget(empty_metadata_widget: ScanMetadata): - widget = empty_metadata_widget - widget._md_schema = ExampleSchema - widget.populate() - - yield widget, widget.widget_dict +def test_fields_use_matching_widgets(metadata_widget: ScanMetadata): + widgets = metadata_widget.form.widgets + for name in ["scan_name", "comment", "sample_name", "str_required", "unsupported_class"]: + assert type(widgets[name]) is QLineEdit, name + assert isinstance(metadata_widget.form.input_widget("str_optional"), QLineEdit) + assert metadata_widget.form.input_widget("str_optional").maxLength() == 23 + assert isinstance(widgets["bool_optional"], OptionalValueWidget) + assert isinstance(widgets["bool_required_default"], QCheckBox) + assert isinstance(widgets["bool_required_nodefault"], QCheckBox) + assert isinstance(widgets["int_default"], QSpinBox) + assert isinstance(widgets["int_nodefault_optional"], OptionalValueWidget) + assert isinstance(widgets["float_nodefault"], BECSpinBox) + decimal = widgets["decimal_dp_limits_nodefault"] + assert type(decimal) is QDoubleSpinBox + assert decimal.decimals() == 2 + assert (decimal.minimum(), decimal.maximum()) == (pytest.approx(1.01), pytest.approx(34.5)) + assert isinstance(widgets["dict_default"], EntryListEditor) -def fill_components(components: dict[str, DynamicFormItem]): - components["sample_name"].setValue("test name") - components["str_optional"].setValue(None) - components["str_required"].setValue("something") - components["bool_optional"].setValue(None) - components["bool_required_nodefault"].setValue(False) - components["int_default"].setValue(21) - components["int_nodefault_optional"].setValue(-10) - components["float_nodefault"].setValue(0.1) - components["decimal_dp_limits_nodefault"].setValue(456.789) - components["dict_default"].setValue({"test_dict": "values"}) - components["unsupported_class"].setValue(r'["set", "item"]') - - -def test_griditems_are_correct_class( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - _, components = metadata_widget - assert isinstance(components["sample_name"], StrFormItem) - assert isinstance(components["str_optional"], StrFormItem) - assert isinstance(components["str_required"], StrFormItem) - assert isinstance(components["bool_optional"], BoolFormItem) - assert isinstance(components["bool_required_default"], BoolFormItem) - assert isinstance(components["bool_required_nodefault"], BoolFormItem) - assert isinstance(components["int_default"], IntFormItem) - assert isinstance(components["int_nodefault_optional"], IntFormItem) - assert isinstance(components["float_nodefault"], FloatDecimalFormItem) - assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalFormItem) - assert isinstance(components["dict_default"], DictFormItem) - assert isinstance(components["unsupported_class"], StrFormItem) - - -def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]): - widget, components = metadata_widget = metadata_widget - fill_components(components) - - assert widget._dict_from_grid() == TEST_DICT - assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"} - - -def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]): - widget, components = metadata_widget = metadata_widget - assert widget._validity_state is None - assert widget._validity_message.text() == "Not yet validated" - - fill_components(components) - widget.validate_form() - assert widget._validity_state is True - assert widget._validity_message.text() == "No errors!" - - components["bool_required_nodefault"]._main_widget.clear() - widget.validate_form() - assert widget._validity_state is False - assert widget._validity_message.text() == "Invalid field: bool_required_nodefault" - assert ( - widget._validity_message.toolTip() == "bool_required_nodefault: Required - please fill in" +def test_required_fields_are_marked(metadata_widget: ScanMetadata): + form = metadata_widget.form + assert form.layout().labelForField(form.field_widget("str_required")).text() == ( + "Str required *" ) - components["bool_required_nodefault"].setValue(True) - - components["float_nodefault"]._main_widget.clear() - widget.validate_form() - assert widget._validity_message.text() == "Invalid field: float_nodefault" - assert widget._validity_message.toolTip() == "float_nodefault: Required - please fill in" - components["float_nodefault"].setValue(True) + assert form.layout().labelForField(form.field_widget("comment")).text() == "Comment" -def test_validation_summary_stays_short( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - widget, components = metadata_widget - fill_components(components) - components["bool_required_nodefault"]._main_widget.clear() - components["float_nodefault"]._main_widget.clear() - widget.validate_form() - summary = widget._validity_summary - assert summary == "2 invalid fields: bool_required_nodefault, float_nodefault" +def test_form_data_includes_extras_and_scan_name(metadata_widget: ScanMetadata, qtbot): + metadata_widget.set_field_values(TEST_VALUES) + received = [] + metadata_widget.form_data_updated.connect(received.append) + + assert metadata_widget.validate_form() + # the emitted metadata is validated, so the Json field arrives parsed + assert received[-1] == TEST_DICT | { + "unsupported_class": ["set", "item"], + "extra_field": "extra_data", + } + # Decimals are sent as floats, like the other numbers + assert isinstance(received[-1]["decimal_dp_limits_nodefault"], float) + assert metadata_widget.get_form_data()["extra_field"] == "extra_data" + + +def test_validation_messages(metadata_widget: ScanMetadata): + assert metadata_widget._validity_message.text() == "Not yet validated" + metadata_widget.set_field_values(TEST_VALUES | {"str_required": ""}) + cleared = [] + metadata_widget.form_data_cleared.connect(cleared.append) + + assert not metadata_widget.validate_form() + assert cleared == [None] + assert metadata_widget.validation_messages() == ["Str required: Required - please fill in"] + assert metadata_widget._validity_message.text() == "Invalid field: Str required" + assert metadata_widget._validity_message.toolTip() == "Str required: Required - please fill in" + assert metadata_widget.form.input_widget("str_required").property("state") == "error" + + metadata_widget.set_field_values({"str_required": "something"}) + assert metadata_widget.validate_form() + assert metadata_widget._validity_message.text() == "No errors!" + assert metadata_widget._validity_message.toolTip() == "" + assert metadata_widget.form.input_widget("str_required").property("state") == "" + + +def test_validation_summary_stays_short(metadata_widget: ScanMetadata): + metadata_widget.set_field_values(TEST_VALUES | {"str_required": ""}) + metadata_widget.set_extra_metadata({"comment": "clash"}) + assert not metadata_widget.validate_form() + summary = metadata_widget._validity_message.text() + assert summary == "2 invalid fields: Str required, Additional metadata" assert "\n" not in summary - assert "errors.pydantic.dev" not in widget._validity_message.toolTip() + assert "errors.pydantic.dev" not in metadata_widget._validity_message.toolTip() -def test_validation_summary_uses_field_titles( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - widget, _ = metadata_widget - with pytest.raises(ValidationError) as excinfo: - ExampleSchema.model_validate(TEST_DICT | {"str_optional": "x" * 30}) - assert "Optional string" in widget._summarize_errors(excinfo.value) +def test_validation_errors_under_each_field(metadata_widget: ScanMetadata): + metadata_widget.update_with_new_scan("two_required") + form = metadata_widget.form + errors = {name: form.field_error_label(name) for name in form.widgets} + shown = {name for name, label in errors.items() if label.isVisibleTo(metadata_widget)} + assert shown == {"sample_id", "operator"} + assert errors["operator"].text() == "Required - please fill in" + assert "errors.pydantic.dev" not in errors["operator"].text() + assert metadata_widget._validity_message.text() == "2 invalid fields: Sample ID, Operator" + + form.input_widget("sample_id").setText("s1") + shown = {name for name, label in errors.items() if label.isVisibleTo(metadata_widget)} + assert shown == {"operator"} + assert metadata_widget._validity_message.text() == "Invalid field: Operator" + + form.input_widget("operator").setText("jan") + assert not any(label.isVisibleTo(metadata_widget) for label in errors.values()) + assert metadata_widget._validity_message.text() == "No errors!" -def test_validation_summary_groups_union_and_list_errors( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - class UnionListSchema(BasicScanMetadata): - u: int | float = Field(title="Union") - lst: list[int] = Field(default_factory=list, title="List") - - widget, _ = metadata_widget - with pytest.raises(ValidationError) as excinfo: - UnionListSchema.model_validate({"u": "x", "lst": [1, "a", "b"]}) - widget._md_schema = UnionListSchema - assert widget._summarize_errors(excinfo.value) == "2 invalid fields: Union, List" +def test_validation_error_line_sits_under_its_field(metadata_widget: ScanMetadata): + form = metadata_widget.form + layout = form.layout() + for name, widget in form.widgets.items(): + field_row, _ = layout.getWidgetPosition(widget) + error_row, role = layout.getWidgetPosition(form.field_error_label(name)) + assert (error_row, role) == (field_row + 1, QFormLayout.ItemRole.FieldRole) -def test_validation_issues_are_readable( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - class ReadableSchema(BasicScanMetadata): - name: str = Field(max_length=3, title="Name") - count: int = Field(title="Count") - value: Decimal = Field(decimal_places=1, title="Value") - - widget, _ = metadata_widget - widget._md_schema = ReadableSchema - with pytest.raises(ValidationError) as excinfo: - ReadableSchema.model_validate({"name": "toolong", "count": None, "value": Decimal("1.25")}) - issues = widget._describe_errors(excinfo.value) - assert [(i.field, i.title, i.entered) for i in issues] == [ - ("name", "Name", "'toolong'"), - ("count", "Count", "empty"), - ("value", "Value", "1.25"), - ] - assert issues[0].problem == "String should have at most 3 characters" - assert issues[1].problem == "Required - please fill in" +def test_validation_error_lines_follow_schema_change(metadata_widget: ScanMetadata): + metadata_widget.update_with_new_scan("other_scan") + form = metadata_widget.form + assert {name for name in form.widgets if form.field_error_label(name)} == { + "scan_name", + "comment", + "sample_name", + } + metadata_widget.update_with_new_scan("two_required") + assert form.field_error_label("operator").isVisibleTo(metadata_widget) + assert form.field_error_label("str_required") is None -def test_validation_issues_keep_nested_locations( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - class Detector(BaseModel): - prefix: str - gain: int - +def test_validation_messages_list_every_failing_entry(metadata_widget: ScanMetadata): class NestedSchema(BasicScanMetadata): positions: list[int] = Field(default_factory=list, title="Positions") - offsets: dict[str, int] = Field(default_factory=dict, title="Offsets") - detector: Detector | None = Field(None, title="Detector") value: int | float = Field(0, title="Value") - widget, _ = metadata_widget - widget._md_schema = NestedSchema + metadata_widget.form.set_model(NestedSchema) with pytest.raises(ValidationError) as excinfo: - NestedSchema.model_validate( - { - "positions": [1, "a", 3, "b"], - "offsets": {"x": 1, "y": "bad"}, - "detector": {"gain": "hi"}, - "value": "x", - } - ) - issues = {issue.field: issue for issue in widget._describe_errors(excinfo.value)} - positions = issues["positions"].problem.split("\n") - assert [line.split(":")[0] for line in positions] == ["[1]", "[3]"] - assert issues["offsets"].problem.startswith("y: Input should be a valid integer") - assert issues["detector"].problem.split("\n") == [ - "prefix: Required - please fill in", - "gain: Input should be a valid integer, unable to parse string as an integer", - ] + NestedSchema.model_validate({"positions": [1, "a", 3, "b"], "value": "x"}) + problems = metadata_widget.form._describe_validation_error(excinfo.value) + assert [line.split(":")[0] for line in problems["positions"].split("\n")] == ["[1]", "[3]"] # union members are tagged with their type name, which is not a location - assert not any( - line.startswith(("int:", "float:")) for line in issues["value"].problem.split("\n") - ) - assert len(issues["value"].problem.split("\n")) == 2 + assert not any(line.startswith(("int:", "float:")) for line in problems["value"].split("\n")) + assert len(problems["value"].split("\n")) == 2 -def test_validation_errors_under_each_field( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - widget, components = metadata_widget - errors = widget._field_errors - assert set(errors) == set(components) - assert not any(label.isVisibleTo(widget) for label in errors.values()) - - fill_components(components) - components["bool_required_nodefault"]._main_widget.clear() - components["float_nodefault"]._main_widget.clear() - widget.validate_form() - shown = {name for name, label in errors.items() if label.isVisibleTo(widget)} - assert shown == {"bool_required_nodefault", "float_nodefault"} - assert errors["float_nodefault"].text() == "Required - please fill in" - assert "errors.pydantic.dev" not in errors["float_nodefault"].text() - - components["bool_required_nodefault"].setValue(True) - widget.validate_form() - shown = {name for name, label in errors.items() if label.isVisibleTo(widget)} - assert shown == {"float_nodefault"} - - components["float_nodefault"].setValue(1.5) - widget.validate_form() - assert not any(label.isVisibleTo(widget) for label in errors.values()) +def test_numbers_clipped_to_limits(metadata_widget: ScanMetadata): + metadata_widget.set_field_values(TEST_VALUES | {"decimal_dp_limits_nodefault": -56}) + assert metadata_widget.get_form_data()["decimal_dp_limits_nodefault"] == pytest.approx(1.01) + assert metadata_widget.validate_form() -def test_validation_error_line_sits_under_its_field( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - widget, components = metadata_widget - grid = widget._form_grid.layout() - rows = list(widget.enumerate_form_widgets()) - assert len(rows) == len(components) # error lines are not form rows - for row in rows: - name = row.label.property("_model_field_name") - index = grid.indexOf(widget._field_errors[name]) - assert grid.getItemPosition(index)[:2] == (row.i + 1, 1) +def test_editing_a_field_revalidates(metadata_widget: ScanMetadata): + metadata_widget.set_field_values(TEST_VALUES) + received = [] + metadata_widget.validity_proc.connect(received.append) + metadata_widget.form.input_widget("str_required").setText("x") + assert received[-1] is True + metadata_widget.form.input_widget("str_required").setText("") + assert received[-1] is False -def test_validation_error_lines_follow_schema_change( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - class OtherSchema(BasicScanMetadata): - count: int = Field(title="Count") +@pytest.mark.parametrize( + "key, message", + [ + ("sample_name", "'sample_name' is already a field of this form."), + ("extra_field", "Key 'extra_field' is used more than once."), + ("", "Enter a key for the value."), + ], +) +def test_invalid_additional_metadata_keys(metadata_widget: ScanMetadata, key, message): + metadata_widget.set_field_values(TEST_VALUES) + editor = metadata_widget.form.extra_fields_section.editor + row = editor.add_entry(key, "value") - widget, _ = metadata_widget - widget.set_schema(OtherSchema) - assert set(widget._field_errors) == {"scan_name", "comment", "sample_name", "count"} - widget.validate_form() - assert widget._field_errors["count"].isVisibleTo(widget) - assert widget._validity_message.text() == "Invalid field: Count" + assert not metadata_widget.validate_form() + assert metadata_widget.validation_messages() == [f"Additional metadata: {message}"] + assert row.key_edit.property("state") == "error" + + row.key_edit.setText("unique_key") + assert metadata_widget.validate_form() + assert row.key_edit.property("state") == "" + assert metadata_widget.get_form_data()["unique_key"] == "value" -def test_numbers_clipped_to_limits( - metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]], -): - widget, components = metadata_widget = metadata_widget - fill_components(components) +def test_blank_additional_metadata_rows_are_ignored(metadata_widget: ScanMetadata): + metadata_widget.set_field_values(TEST_VALUES) + metadata_widget.form.extra_fields_section.editor.add_button.click() - components["decimal_dp_limits_nodefault"].setValue(-56) - assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01) - widget.validate_form() - assert widget._validity_message.text() == "No errors!" + assert metadata_widget.validate_form() + assert "" not in metadata_widget.get_form_data() + + +def test_switching_scans_keeps_shared_values_and_extras(metadata_widget: ScanMetadata): + metadata_widget.set_field_values(TEST_VALUES | {"comment": "keep me"}) + metadata_widget.update_with_new_scan("other_scan") + + assert metadata_widget._md_schema is BasicScanMetadata + assert set(metadata_widget.form.widgets) == {"scan_name", "comment", "sample_name"} + assert metadata_widget.form.input_widget("scan_name").placeholderText() == "other_scan" + assert metadata_widget.get_form_data() == { + "scan_name": "other_scan", + "comment": "keep me", + "sample_name": "test name", + "extra_field": "extra_data", + } + + +def test_typed_scan_name_is_kept(metadata_widget: ScanMetadata): + metadata_widget.set_field_values({"scan_name": "my alignment"}) + assert metadata_widget.get_form_data()["scan_name"] == "my alignment" + + +def test_hide_optional_metadata(metadata_widget: ScanMetadata): + section = metadata_widget.form.extra_fields_section + assert section.isVisibleTo(metadata_widget) + metadata_widget.hide_optional_metadata = True + assert metadata_widget.hide_optional_metadata + assert not section.isVisibleTo(metadata_widget) + + +def test_scan_metadata_form_drops_deleted_extra_row(qtbot): + widget = create_widget(qtbot, ScanMetadata, initial_extras=[["k1", "v1"], ["k2", "v2"]]) + received: list[dict] = [] + widget.form_data_updated.connect(received.append) + editor = widget.form.extra_fields_section.editor + + editor.rows()[0].remove_button.click() + + assert editor.value() == {"k2": "v2"} + assert received, "deleting a row must re-validate the form" + assert "k1" not in received[-1] + assert received[-1]["k2"] == "v2" @pytest.fixture