mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 16:11:27 +01:00
328 lines
13 KiB
Python
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"
|