mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
352 lines
13 KiB
Python
352 lines
13 KiB
Python
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.types import Json
|
|
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
|
|
|
|
from bec_widgets.utils.forms_from_types.items import (
|
|
BoolFormItem,
|
|
DictFormItem,
|
|
DynamicFormItem,
|
|
FloatDecimalFormItem,
|
|
IntFormItem,
|
|
StrFormItem,
|
|
)
|
|
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
|
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
|
|
|
# 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())
|
|
|
|
|
|
TEST_DICT = {
|
|
"scan_name": "",
|
|
"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 example_md():
|
|
return ExampleSchema.model_validate(TEST_DICT)
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_metadata_widget(qtbot):
|
|
widget = ScanMetadata()
|
|
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
|
|
qtbot.addWidget(widget)
|
|
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 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"
|
|
)
|
|
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)
|
|
|
|
|
|
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"
|
|
assert "\n" not in summary
|
|
assert "errors.pydantic.dev" not in 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_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_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_issues_keep_nested_locations(
|
|
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
|
|
):
|
|
class Detector(BaseModel):
|
|
prefix: str
|
|
gain: int
|
|
|
|
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
|
|
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",
|
|
]
|
|
# 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
|
|
|
|
|
|
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_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_validation_error_lines_follow_schema_change(
|
|
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
|
|
):
|
|
class OtherSchema(BasicScanMetadata):
|
|
count: int = Field(title="Count")
|
|
|
|
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"
|
|
|
|
|
|
def test_numbers_clipped_to_limits(
|
|
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
|
|
):
|
|
widget, components = metadata_widget = metadata_widget
|
|
fill_components(components)
|
|
|
|
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!"
|
|
|
|
|
|
@pytest.fixture
|
|
def table():
|
|
table = DictBackedTable(
|
|
initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
|
|
)
|
|
yield table
|
|
table._table_model.deleteLater()
|
|
table._table_view.deleteLater()
|
|
table.deleteLater()
|
|
|
|
|
|
def test_additional_metadata_table_add_row(table: DictBackedTable):
|
|
assert table._table_model.rowCount() == 3
|
|
table._add_button.click()
|
|
assert table._table_model.rowCount() == 4
|
|
|
|
|
|
def test_additional_metadata_table_delete_row(table: DictBackedTable):
|
|
assert table._table_model.rowCount() == 3
|
|
m = table._table_view.selectionModel()
|
|
item = table._table_view.indexAt(QPoint(0, 0)).siblingAtRow(1)
|
|
m.select(item, QItemSelectionModel.SelectionFlag.Select)
|
|
table.delete_selected_rows()
|
|
assert table._table_model.rowCount() == 2
|
|
assert list(table.dump_dict().keys()) == ["key1", "key3"]
|
|
|
|
|
|
def test_additional_metadata_allows_changes(table: DictBackedTable):
|
|
assert table._table_model.rowCount() == 3
|
|
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
|
|
table._table_model.setData(table._table_model.index(1, 0), "key4", Qt.ItemDataRole.EditRole)
|
|
assert list(table.dump_dict().keys()) == ["key1", "key4", "key3"]
|
|
|
|
|
|
def test_additional_metadata_doesnt_allow_dupes(table: DictBackedTable):
|
|
assert table._table_model.rowCount() == 3
|
|
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
|
|
table._table_model.setData(table._table_model.index(1, 0), "key1", Qt.ItemDataRole.EditRole)
|
|
assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]
|