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"