Files
bec_widgets/tests/unit_tests/test_scan_metadata.py
T

328 lines
13 KiB
Python

from decimal import Decimal
from unittest import mock
import pytest
from bec_lib import messages
from bec_lib.metadata_schema import BasicScanMetadata
from pydantic import Field, ValidationError
from pydantic.types import Json
from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QFormLayout, QLineEdit, QSpinBox
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,
TrueFalseEntry,
)
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
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
class ExampleSchema(BasicScanMetadata):
str_optional: str | None = Field(
None, title="Optional string", description="an optional string", max_length=23
)
str_required: str
bool_optional: bool | None = Field(None)
bool_required_default: bool = Field(True)
bool_required_nodefault: bool = Field()
int_default: int = Field(123)
int_nodefault_optional: int | None = Field(lt=-1, ge=-44)
float_nodefault: float
decimal_dp_limits_nodefault: Decimal = Field(Decimal(1.23), decimal_places=2, gt=1, le=34.5)
dict_default: dict = Field(default_factory=dict)
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": "example_scan",
"comment": "",
"sample_name": "test name",
"str_optional": None,
"str_required": "something",
"bool_optional": None,
"bool_required_default": True,
"bool_required_nodefault": False,
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": pytest.approx(0.1),
"decimal_dp_limits_nodefault": pytest.approx(34.5),
"dict_default": {"test_dict": "values"},
"unsupported_class": '["set", "item"]',
}
@pytest.fixture
def schema_registry(monkeypatch):
monkeypatch.setattr(
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"example_scan": ExampleSchema, "two_required": TwoRequiredSchema},
)
@pytest.fixture
def metadata_widget(qtbot, schema_registry):
widget = create_widget(
qtbot,
ScanMetadata,
scan_name="example_scan",
initial_extras=[["extra_field", "extra_data"]],
)
yield widget
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"], TrueFalseEntry)
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 isinstance(decimal, 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 test_required_fields_are_marked(metadata_widget: ScanMetadata):
form = metadata_widget.form
assert form.layout().labelForField(form.field_widget("str_required")).text() == (
"Str required *"
)
assert form.layout().labelForField(form.field_widget("comment")).text() == "Comment"
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 Json field stays JSON text, which the scan queue validates again
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_emitted_metadata_is_accepted_by_the_scan_queue(metadata_widget: ScanMetadata):
metadata_widget.set_field_values(TEST_VALUES)
received = []
metadata_widget.form_data_updated.connect(received.append)
assert metadata_widget.validate_form()
with mock.patch.object(messages, "get_metadata_schema_for_scan", return_value=ExampleSchema):
msg = messages.ScanQueueMessage(
scan_type="example_scan", parameter={}, metadata={"user_metadata": received[-1]}
)
assert msg.metadata["user_metadata"]["unsupported_class"] == '["set","item"]'
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() == "Validation successful"
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 metadata_widget._validity_message.toolTip()
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() == "Validation successful"
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_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_messages_list_every_failing_entry(metadata_widget: ScanMetadata):
class NestedSchema(BasicScanMetadata):
positions: list[int] = Field(default_factory=list, title="Positions")
value: int | float = Field(0, title="Value")
metadata_widget.form.set_model(NestedSchema)
with pytest.raises(ValidationError) as excinfo:
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 problems["value"].split("\n"))
assert len(problems["value"].split("\n")) == 2
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_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
@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")
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_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()
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"