mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-12-30 18:51:19 +01:00
1560 lines
65 KiB
Python
1560 lines
65 KiB
Python
"""Unit tests for device_manager_components module."""
|
|
|
|
from threading import Event
|
|
from typing import Generator
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
import yaml
|
|
from bec_lib.atlas_models import Device as DeviceModel
|
|
from ophyd_devices.interfaces.device_config_templates.ophyd_templates import (
|
|
OPHYD_DEVICE_TEMPLATES,
|
|
EpicsMotorDeviceConfigTemplate,
|
|
)
|
|
from ophyd_devices.utils.static_device_test import TestResult
|
|
from qtpy import QtCore, QtGui, QtWidgets
|
|
|
|
from bec_widgets.utils.bec_list import BECList
|
|
from bec_widgets.utils.colors import get_accent_colors
|
|
from bec_widgets.widgets.control.device_manager import DeviceTable, DMConfigView, DocstringView
|
|
from bec_widgets.widgets.control.device_manager.components import docstring_to_markdown
|
|
from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD
|
|
from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import (
|
|
DeviceConfigTemplate,
|
|
)
|
|
from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import (
|
|
DEVICE_CONFIG_FIELDS,
|
|
DEVICE_FIELDS,
|
|
DeviceConfigField,
|
|
DeviceTagsWidget,
|
|
InputLineEdit,
|
|
LimitInputWidget,
|
|
OnFailureComboBox,
|
|
ParameterValueWidget,
|
|
ReadoutPriorityComboBox,
|
|
_try_literal_eval,
|
|
)
|
|
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
|
|
DeviceTableRow,
|
|
)
|
|
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import (
|
|
DeviceTest,
|
|
LegendLabel,
|
|
OphydValidation,
|
|
ThreadPoolManager,
|
|
)
|
|
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
|
|
ConfigStatus,
|
|
ConnectionStatus,
|
|
DeviceTestModel,
|
|
format_error_to_md,
|
|
get_validation_icons,
|
|
)
|
|
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.validation_list_item import (
|
|
ValidationButton,
|
|
ValidationDialog,
|
|
ValidationListItem,
|
|
)
|
|
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
|
|
|
|
|
class TestConstants:
|
|
"""Test class for constants and configuration values."""
|
|
|
|
def test_headers_help_md(self):
|
|
"""Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format."""
|
|
assert isinstance(HEADERS_HELP_MD, dict)
|
|
expected_keys = {
|
|
"valid",
|
|
"connect",
|
|
"name",
|
|
"deviceClass",
|
|
"readoutPriority",
|
|
"deviceTags",
|
|
"enabled",
|
|
"readOnly",
|
|
"onFailure",
|
|
"softwareTrigger",
|
|
"description",
|
|
}
|
|
assert set(HEADERS_HELP_MD.keys()) == expected_keys
|
|
for _, value in HEADERS_HELP_MD.items():
|
|
assert isinstance(value["long"], str)
|
|
assert isinstance(value["short"], str)
|
|
assert value["long"].startswith("## ") # Each entry should start with a markdown header
|
|
|
|
|
|
# Test utility classes for docstring testing
|
|
class NumPyStyleClass:
|
|
"""Perform simple signal operations.
|
|
|
|
Parameters
|
|
----------
|
|
data : numpy.ndarray
|
|
Input signal data.
|
|
|
|
Attributes
|
|
----------
|
|
data : numpy.ndarray
|
|
The original signal data.
|
|
|
|
Returns
|
|
-------
|
|
SignalProcessor
|
|
An initialized signal processor instance.
|
|
"""
|
|
|
|
|
|
class GoogleStyleClass:
|
|
"""Analyze spectral properties of a signal.
|
|
|
|
Args:
|
|
frequencies (list[float]): Frequency bins.
|
|
amplitudes (list[float]): Corresponding amplitude values.
|
|
|
|
Returns:
|
|
dict: A dictionary with spectral analysis results.
|
|
|
|
Raises:
|
|
ValueError: If input lists are of unequal length.
|
|
"""
|
|
|
|
|
|
class TestDocstringView:
|
|
"""Test class for DocstringView component."""
|
|
|
|
@pytest.fixture
|
|
def docstring_view(self, qtbot):
|
|
"""Fixture to create a DocstringView instance."""
|
|
view = DocstringView()
|
|
qtbot.addWidget(view)
|
|
qtbot.waitExposed(view)
|
|
yield view
|
|
|
|
def test_docstring_to_markdown(self):
|
|
"""Test the docstring_to_markdown function with a sample class."""
|
|
numpy_md = docstring_to_markdown(NumPyStyleClass)
|
|
assert "# NumPyStyleClass" in numpy_md
|
|
assert "### Parameters" in numpy_md
|
|
assert "### Attributes" in numpy_md
|
|
assert "### Returns" in numpy_md
|
|
assert "```" in numpy_md # Check for code block formatting
|
|
|
|
google_md = docstring_to_markdown(GoogleStyleClass)
|
|
assert "# GoogleStyleClass" in google_md
|
|
assert "### Args" in google_md
|
|
assert "### Returns" in google_md
|
|
assert "### Raises" in google_md
|
|
assert "```" in google_md # Check for code block formatting
|
|
|
|
def test_on_select_config(self, docstring_view: DocstringView):
|
|
"""Test the on_select_config method with a sample configuration."""
|
|
with (
|
|
mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class,
|
|
mock.patch.object(docstring_view, "_set_text") as mock_set_text,
|
|
):
|
|
# Test with single device
|
|
docstring_view.on_select_config([{"test": {"deviceClass": "NumPyStyleClass"}}])
|
|
mock_set_device_class.assert_called_once_with("NumPyStyleClass")
|
|
|
|
mock_set_device_class.reset_mock()
|
|
# Test with multiple devices, should not show anything
|
|
docstring_view.on_select_config(
|
|
[
|
|
{"test": {"deviceClass": "NumPyStyleClass"}},
|
|
{"test": {"deviceClass": "GoogleStyleClass"}},
|
|
]
|
|
)
|
|
mock_set_device_class.assert_not_called()
|
|
mock_set_text.assert_called_once_with("")
|
|
|
|
def test_set_device_class(self, docstring_view: DocstringView):
|
|
"""Test the set_device_class method."""
|
|
with mock.patch(
|
|
"bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class"
|
|
) as mock_get_plugin_class:
|
|
|
|
# Mock a valid class retrieval
|
|
mock_get_plugin_class.return_value = NumPyStyleClass
|
|
docstring_view.set_device_class("NumPyStyleClass")
|
|
assert "NumPyStyleClass" in docstring_view.toPlainText()
|
|
assert "Parameters" in docstring_view.toPlainText()
|
|
|
|
# Mock an invalid class retrieval
|
|
mock_get_plugin_class.side_effect = ImportError("Class not found")
|
|
docstring_view.set_device_class("NonExistentClass")
|
|
assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText()
|
|
|
|
# Test if READY_TO_VIEW is False
|
|
with mock.patch(
|
|
"bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW",
|
|
False,
|
|
):
|
|
call_count = mock_get_plugin_class.call_count
|
|
docstring_view.set_device_class("NumPyStyleClass") # Should do nothing
|
|
assert mock_get_plugin_class.call_count == call_count # No new calls made
|
|
|
|
|
|
class TestDMConfigView:
|
|
"""Test class for DMConfigView component."""
|
|
|
|
@pytest.fixture
|
|
def dm_config_view(self, qtbot):
|
|
"""Fixture to create a DMConfigView instance."""
|
|
view = DMConfigView()
|
|
qtbot.addWidget(view)
|
|
qtbot.waitExposed(view)
|
|
yield view
|
|
|
|
def test_initialization(self, dm_config_view: DMConfigView):
|
|
"""Test DMConfigView proper initialization."""
|
|
# Check that the stacked layout is set up correctly
|
|
assert dm_config_view.stacked_layout is not None
|
|
assert dm_config_view.stacked_layout.count() == 2
|
|
# Assert Monaco editor is initialized
|
|
assert dm_config_view.monaco_editor.get_language() == "yaml"
|
|
assert dm_config_view.monaco_editor.editor._readonly is True
|
|
|
|
# Check overlay widget
|
|
assert dm_config_view._overlay_widget is not None
|
|
assert dm_config_view._overlay_widget.text() == "Select a single device to view its config."
|
|
|
|
# Check that overlay is initially shown
|
|
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
|
|
|
|
def test_on_select_config(self, dm_config_view: DMConfigView):
|
|
"""Test DMConfigView on_select_config with empty selection."""
|
|
# Test with empty list of configs
|
|
dm_config_view.on_select_config([])
|
|
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
|
|
|
|
# Test with a single config
|
|
cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}]
|
|
dm_config_view.on_select_config(cfgs)
|
|
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor
|
|
text = yaml.dump(cfgs[0], default_flow_style=False)
|
|
assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n")
|
|
|
|
# Test with multiple configs
|
|
cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}]
|
|
dm_config_view.on_select_config(cfgs)
|
|
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
|
|
assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged
|
|
|
|
|
|
class TestDeviceTableRow:
|
|
"""Test class for DeviceTableRow component."""
|
|
|
|
@pytest.fixture
|
|
def sample_device_data(self) -> dict:
|
|
"""Sample device data for testing."""
|
|
return {
|
|
"name": "test_motor",
|
|
"deviceClass": "ophyd.EpicsMotor",
|
|
"readoutPriority": "baseline",
|
|
"onFailure": "retry",
|
|
"deviceTags": {"motors", "positioning"},
|
|
"description": "X-axis positioning motor",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": False,
|
|
}
|
|
|
|
@pytest.fixture
|
|
def device_table_row(self, sample_device_data: dict):
|
|
"""Fixture to create a DeviceTableRow instance."""
|
|
row = DeviceTableRow(data=sample_device_data)
|
|
yield row
|
|
|
|
def test_initialization(self, device_table_row: DeviceTableRow, sample_device_data: dict):
|
|
"""Test DeviceTableRow initialization with sample data."""
|
|
expected_keys = list(DeviceModel.model_fields.keys())
|
|
for key in expected_keys:
|
|
assert key in device_table_row.data
|
|
if key in sample_device_data:
|
|
assert device_table_row.data[key] == sample_device_data[key]
|
|
assert device_table_row.validation_status == (
|
|
ConfigStatus.UNKNOWN,
|
|
ConnectionStatus.UNKNOWN,
|
|
)
|
|
device_table_row.set_validation_status(ConfigStatus.VALID, ConnectionStatus.CONNECTED)
|
|
assert device_table_row.validation_status == (
|
|
ConfigStatus.VALID,
|
|
ConnectionStatus.CONNECTED,
|
|
)
|
|
new_data = sample_device_data.copy()
|
|
new_data["name"] = "updated_motor"
|
|
device_table_row.set_data(new_data)
|
|
assert device_table_row.data["name"] == new_data.get("name", "")
|
|
assert device_table_row.validation_status == (
|
|
ConfigStatus.UNKNOWN,
|
|
ConnectionStatus.UNKNOWN,
|
|
)
|
|
|
|
|
|
class TestDeviceTable:
|
|
"""Test class for DeviceTable component."""
|
|
|
|
@pytest.fixture
|
|
def device_table(self, qtbot) -> Generator[DeviceTable, None, None]:
|
|
"""Fixture to create a DeviceTable instance."""
|
|
table = DeviceTable()
|
|
qtbot.addWidget(table)
|
|
qtbot.waitExposed(table)
|
|
yield table
|
|
|
|
@pytest.fixture
|
|
def sample_devices(self):
|
|
"""Sample device configurations for testing."""
|
|
return [
|
|
{
|
|
"name": "motor_x",
|
|
"deviceClass": "EpicsMotor",
|
|
"readoutPriority": "baseline",
|
|
"onFailure": "retry",
|
|
"deviceTags": ["motors"],
|
|
"description": "X-axis motor",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": False,
|
|
},
|
|
{
|
|
"name": "detector_main",
|
|
"deviceClass": "ophyd.EpicsSignal",
|
|
"readoutPriority": "async",
|
|
"onFailure": "buffer",
|
|
"deviceTags": ["detectors", "main"],
|
|
"description": "Main area detector",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": True,
|
|
},
|
|
]
|
|
|
|
def test_initialization(self, device_table: DeviceTable):
|
|
"""Test DeviceTable initialization."""
|
|
# Check table setup
|
|
assert device_table.table.columnCount() == 11
|
|
assert device_table.table.rowCount() == 0
|
|
|
|
# Check headers
|
|
expected_headers = [
|
|
"Valid",
|
|
"Connect",
|
|
"Name",
|
|
"Device Class",
|
|
"Readout Priority",
|
|
"On Failure",
|
|
"Device Tags",
|
|
"Description",
|
|
"Enabled",
|
|
"Read Only",
|
|
"Software Trigger",
|
|
]
|
|
for i, expected_header in enumerate(expected_headers):
|
|
actual_header = device_table.table.horizontalHeaderItem(i).text()
|
|
assert actual_header == expected_header
|
|
|
|
# Check search functionality is set up
|
|
assert device_table.search_input is not None
|
|
assert device_table.fuzzy_is_disabled.isChecked() is False
|
|
assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows
|
|
|
|
def test_add_row(self, device_table: DeviceTable, sample_devices: dict):
|
|
"""Test adding a single device row."""
|
|
device_table.add_device_configs([sample_devices[0]])
|
|
|
|
# Verify row was added
|
|
assert device_table.table.rowCount() == 1
|
|
assert len(device_table.row_data) == 1
|
|
assert "motor_x" in device_table.row_data
|
|
|
|
# If row is added again, it should overwrite
|
|
sample_devices[0]["deviceClass"] = "UpdateClass"
|
|
device_table.add_device_configs([sample_devices[0]])
|
|
assert device_table.table.rowCount() == 1
|
|
assert len(device_table.row_data) == 1
|
|
row_data = device_table.row_data["motor_x"]
|
|
assert row_data is not None
|
|
assert row_data.data.get("deviceClass") == "UpdateClass"
|
|
assert device_table._get_cell_data(0, 3) == "UpdateClass" # DeviceClass column
|
|
assert device_table._get_cell_data(0, 2) == "motor_x" # Name column
|
|
assert device_table._get_cell_data(0, 0) == "" # Icon column, no text
|
|
assert device_table._get_cell_data(0, 9) == False # Check Enabled column
|
|
assert device_table.table.item(0, 9).checkState() == QtCore.Qt.CheckState.Unchecked
|
|
config_status_item = device_table.table.item(0, 0)
|
|
assert (
|
|
config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.UNKNOWN.value
|
|
)
|
|
|
|
def test_update_row(self, device_table: DeviceTable, sample_devices: dict):
|
|
"""Test updating an existing device row."""
|
|
device_table.add_device_configs([sample_devices[0]])
|
|
|
|
assert "motor_x" in device_table.row_data
|
|
# Update the existing row
|
|
row: DeviceTableRow = device_table.row_data["motor_x"]
|
|
assert row.data["description"] == "X-axis motor"
|
|
# Change description
|
|
sample_devices[0]["description"] = "Updated X-axis motor"
|
|
device_table._update_row(sample_devices[0])
|
|
row: DeviceTableRow = device_table.row_data["motor_x"]
|
|
assert row.data["description"] == "Updated X-axis motor"
|
|
assert row.validation_status == (ConfigStatus.UNKNOWN, ConnectionStatus.UNKNOWN)
|
|
# Update validation status
|
|
device_table.update_device_validation(
|
|
sample_devices[0],
|
|
ConfigStatus.VALID.value,
|
|
ConnectionStatus.CONNECTED.value,
|
|
validation_msg="",
|
|
)
|
|
assert row.validation_status == (ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value)
|
|
config_status_item = device_table.table.item(0, 0)
|
|
assert config_status_item.data(QtCore.Qt.ItemDataRole.UserRole) == ConfigStatus.VALID.value
|
|
|
|
#####################
|
|
##### Test public API
|
|
#####################
|
|
|
|
def test_set_device_config(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test set device configs methods, must also emit the appropriate signal."""
|
|
with mock.patch.object(device_table, "clear_device_configs") as mock_clear_configs:
|
|
###########
|
|
# Test cases I.
|
|
# First use case, adding new configs to empty table
|
|
device_table.set_device_config(sample_devices)
|
|
|
|
assert device_table.table.rowCount() == 2
|
|
assert mock_clear_configs.call_count == 1
|
|
|
|
# II.
|
|
# Second use case, replacing existing configs
|
|
device_table.set_device_config(sample_devices)
|
|
assert device_table.table.rowCount() == 2
|
|
assert mock_clear_configs.call_count == 2
|
|
|
|
def test_clear_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test clearing device configurations."""
|
|
device_table.add_device_configs(sample_devices)
|
|
assert device_table.table.rowCount() == 2
|
|
##########
|
|
# Callbacks
|
|
container = []
|
|
|
|
def _config_changed_cb(*args, **kwargs):
|
|
container.append((args, kwargs))
|
|
|
|
device_table.device_configs_changed.connect(_config_changed_cb)
|
|
|
|
###########
|
|
# Test cases
|
|
# I.
|
|
# First use case, adding new configs to empty table
|
|
expected_calls = 1
|
|
with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls):
|
|
device_table.clear_device_configs()
|
|
|
|
assert len(container) == 1
|
|
assert device_table.table.rowCount() == 0
|
|
|
|
def test_add_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test add device configs method under various scenarios."""
|
|
|
|
##########
|
|
# Callbacks
|
|
container = []
|
|
|
|
def _config_changed_cb(*args, **kwargs):
|
|
container.append((args, kwargs))
|
|
|
|
device_table.device_configs_changed.connect(_config_changed_cb)
|
|
|
|
###########
|
|
# Test cases
|
|
# I.
|
|
# First use case, adding new configs to empty table
|
|
expected_calls = 1
|
|
with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls):
|
|
device_table.add_device_configs(sample_devices)
|
|
|
|
assert len(container) == 1
|
|
assert container[0][0][0] == sample_devices
|
|
assert container[0][0][1] is True
|
|
assert device_table.table.rowCount() == 2
|
|
|
|
# II.
|
|
# If added again, old configs should be removed first, and new ones added
|
|
# Reset container
|
|
container = []
|
|
expected_calls = 2
|
|
with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls):
|
|
device_table.add_device_configs(sample_devices)
|
|
|
|
assert len(container) == 2
|
|
assert container[0][0][1] is False
|
|
assert container[1][0][0] == sample_devices
|
|
assert container[1][0][1] is True
|
|
|
|
# Verify rows were added
|
|
assert device_table.table.rowCount() == 2
|
|
assert len(device_table.row_data) == 2
|
|
assert "motor_x" in device_table.row_data
|
|
assert "detector_main" in device_table.row_data
|
|
|
|
def test_update_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test updating device configurations."""
|
|
|
|
# Callbacks
|
|
container = []
|
|
|
|
def _config_changed_cb(*args, **kwargs):
|
|
container.append((args, kwargs))
|
|
|
|
device_table.device_configs_changed.connect(_config_changed_cb)
|
|
|
|
# First case I.
|
|
# Update to empty table should add rows, and emit signal with added=True
|
|
expected_calls = 1
|
|
|
|
with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls) as blocker:
|
|
device_table.update_device_configs(sample_devices)
|
|
|
|
# Verify signal emission
|
|
assert len(container) == 1
|
|
assert container[0][0][0] == sample_devices
|
|
assert container[0][0][1] is True
|
|
|
|
# Second case II.
|
|
# Update existing configs should modify rows, and change the validation status to unknown
|
|
# for the device that was changed
|
|
container = []
|
|
sample_devices[0]["description"] = "Modified description"
|
|
expected_calls = 1
|
|
with qtbot.waitSignals([device_table.device_configs_changed] * expected_calls):
|
|
device_table.update_device_configs(sample_devices)
|
|
|
|
# Verify signal emission
|
|
assert len(container) == 1
|
|
assert container[0][0][0] == [sample_devices[0]]
|
|
assert container[0][0][1] is True
|
|
|
|
def test_get_device_config(self, device_table: DeviceTable, sample_devices: dict):
|
|
"""Test retrieving device configurations."""
|
|
device_table.add_device_configs(sample_devices)
|
|
|
|
retrieved_configs = device_table.get_device_config()
|
|
assert len(retrieved_configs) == 2
|
|
|
|
# Check that we can find our test devices
|
|
device_names = [config["name"] for config in retrieved_configs]
|
|
assert "motor_x" in device_names
|
|
assert "detector_main" in device_names
|
|
|
|
def test_search_functionality(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test search/filter functionality."""
|
|
device_table.add_device_configs(sample_devices)
|
|
|
|
# Test filtering by name
|
|
qtbot.keyClicks(device_table.search_input, "motor")
|
|
qtbot.wait(100) # Allow filter to apply
|
|
|
|
# Should show only motor device
|
|
visible_rows = 0
|
|
for row in range(device_table.table.rowCount()):
|
|
if not device_table.table.isRowHidden(row):
|
|
visible_rows += 1
|
|
assert visible_rows == 1
|
|
|
|
def test_remove_device_configs(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test removing device configurations."""
|
|
device_table.add_device_configs(sample_devices)
|
|
assert device_table.table.rowCount() == 2
|
|
|
|
# Remove one device
|
|
with qtbot.waitSignal(device_table.device_configs_changed) as blocker:
|
|
device_table.remove_device_configs([sample_devices[0]])
|
|
|
|
# Verify signal emission
|
|
emitted_configs, added = blocker.args
|
|
assert len(emitted_configs) == 1
|
|
assert added is False
|
|
|
|
# Verify row was removed
|
|
assert device_table.table.rowCount() == 1
|
|
assert "motor_x" not in device_table.row_data
|
|
assert "detector_main" in device_table.row_data
|
|
|
|
def test_validation_status_update(self, device_table: DeviceTable, sample_devices: dict):
|
|
"""Test updating validation status."""
|
|
device_table: DeviceTable
|
|
device_table.add_device_configs(sample_devices)
|
|
|
|
# Update validation status for one device
|
|
device_table.update_device_validation(
|
|
sample_devices[0],
|
|
ConfigStatus.VALID,
|
|
ConnectionStatus.CONNECTED,
|
|
validation_msg="Test passed",
|
|
)
|
|
|
|
# Verify status was updated in the row
|
|
motor_row = device_table.row_data["motor_x"]
|
|
assert motor_row.validation_status == (ConfigStatus.VALID, ConnectionStatus.CONNECTED)
|
|
|
|
def test_selection_handling(self, device_table: DeviceTable, sample_devices: dict, qtbot):
|
|
"""Test device selection and signal emission."""
|
|
device_table.add_device_configs(sample_devices)
|
|
|
|
# Select first row
|
|
with qtbot.waitSignal(device_table.selected_devices) as blocker:
|
|
device_table.table.selectRow(0)
|
|
|
|
# Verify selection signal was emitted
|
|
selected_configs = blocker.args[0]
|
|
assert len(selected_configs) == 1
|
|
assert list(selected_configs[0].keys())[0] in ["motor_x", "detector_main"]
|
|
|
|
|
|
class TestOphydValidation:
|
|
"""
|
|
Test class for the Ophyd test module. This tests the OphydValidation widget,
|
|
the validation list items and dialog, and the utility functions related to
|
|
device testing and validation.
|
|
"""
|
|
|
|
################
|
|
### Ophyd_test_utils tests
|
|
################
|
|
|
|
def test_format_error_to_md(self):
|
|
"""Test the format_error_to_md utility function."""
|
|
device_name = "non_existing_device"
|
|
error_msg = """ERROR: non_existing_device is not valid: 3 validation errors for Device\nenabled\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\ndeviceClass\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nreadoutPriority\n Field required [type=missing, input_value={'name': 'non_existing_de...e': 'NonExistingDevice'}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.11/v/missing\nERROR: non_existing_device is not valid: 'deviceClass'"""
|
|
md_output = format_error_to_md(device_name, error_msg)
|
|
assert f"## Error for {device_name}\n\n**{device_name} is not valid**" in md_output
|
|
assert "3 validation errors for Device" in md_output
|
|
|
|
def test_description_validation_status(self):
|
|
"""Test descriptions for ConfigStatus enum values."""
|
|
# ConfigStatus descriptions
|
|
assert ConfigStatus.VALID.description() == "Valid Configuration"
|
|
assert ConfigStatus.INVALID.description() == "Invalid Configuration"
|
|
assert ConfigStatus.UNKNOWN.description() == "Unknown"
|
|
|
|
# ConnectionStatus descriptions
|
|
assert ConnectionStatus.CANNOT_CONNECT.description() == "Cannot Connect"
|
|
assert ConnectionStatus.CAN_CONNECT.description() == "Can Connect"
|
|
assert ConnectionStatus.CONNECTED.description() == "Connected and Loaded"
|
|
assert ConnectionStatus.UNKNOWN.description() == "Unknown"
|
|
|
|
def test_device_test_model(self):
|
|
"""Test the DeviceTestModel"""
|
|
data = {
|
|
"uuid": "1234",
|
|
"device_name": "test_device",
|
|
"device_config": {"name": "test_device", "deviceClass": "TestClass"},
|
|
"config_status": ConfigStatus.VALID.value,
|
|
"connection_status": ConnectionStatus.CONNECTED.value,
|
|
"validation_messages": "All good",
|
|
}
|
|
model = DeviceTestModel.model_validate(data)
|
|
assert model.uuid == "1234"
|
|
assert model.device_name == "test_device"
|
|
|
|
def test_get_validation_icons(self):
|
|
"""Test the get_validation_icons utility function."""
|
|
colors = get_accent_colors()
|
|
icons = get_validation_icons(colors, (16, 16))
|
|
|
|
# Check that icons for all statuses are present
|
|
for status in ConfigStatus:
|
|
assert status in icons["config_status"]
|
|
assert isinstance(icons["config_status"][status], QtGui.QIcon)
|
|
|
|
for status in ConnectionStatus:
|
|
assert status in icons["connection_status"]
|
|
assert isinstance(icons["connection_status"][status], QtGui.QIcon)
|
|
|
|
################
|
|
### ValidationListItem tests
|
|
################
|
|
|
|
@pytest.fixture
|
|
def validation_button(self, qtbot):
|
|
"""Fixture to create a ValidationButton instance."""
|
|
colors = get_accent_colors()
|
|
icons = get_validation_icons(colors, (16, 16))
|
|
icon = icons["config_status"][ConfigStatus.VALID.value]
|
|
button = ValidationButton(icon=icon)
|
|
|
|
qtbot.addWidget(button)
|
|
qtbot.waitExposed(button)
|
|
yield button
|
|
|
|
def test_validation_button_initialization(self, validation_button: ValidationButton):
|
|
"""Test ValidationButton initialization."""
|
|
assert validation_button.isFlat() is True
|
|
assert validation_button.isEnabled() is True
|
|
assert isinstance(validation_button.icon(), QtGui.QIcon)
|
|
assert validation_button.styleSheet() == ""
|
|
validation_button.setEnabled(False)
|
|
assert validation_button.styleSheet() == validation_button.transparent_style
|
|
|
|
@pytest.fixture
|
|
def validation_dialog(self, qtbot):
|
|
"""Fixture for ValidationDialog."""
|
|
dialog = ValidationDialog()
|
|
qtbot.addWidget(dialog)
|
|
qtbot.waitExposed(dialog)
|
|
yield dialog
|
|
|
|
def test_validation_dialog(self, validation_dialog: ValidationDialog, qtbot):
|
|
"""Test ValidationDialog initialization."""
|
|
assert validation_dialog.timeout_spin.value() == 5
|
|
assert validation_dialog.connect_checkbox.isChecked() is False
|
|
assert validation_dialog.force_connect_checkbox.isChecked() is False
|
|
|
|
# Change timeout
|
|
validation_dialog.timeout_spin.setValue(10)
|
|
# Result should not update yet
|
|
assert validation_dialog.result() == (5, False, False)
|
|
# Click accept
|
|
with qtbot.waitSignal(validation_dialog.accepted):
|
|
qtbot.mouseClick(
|
|
validation_dialog.button_box.button(QtWidgets.QDialogButtonBox.Ok),
|
|
QtCore.Qt.LeftButton,
|
|
)
|
|
assert validation_dialog.result() == (10, False, False)
|
|
|
|
@pytest.fixture
|
|
def device_model(self):
|
|
"""Fixture to create a sample DeviceTestModel instance."""
|
|
config = DeviceModel(
|
|
name="test_device", deviceClass="TestClass", readoutPriority="baseline", enabled=True
|
|
)
|
|
data = {
|
|
"uuid": "1234",
|
|
"device_name": config.name,
|
|
"device_config": config.model_dump(),
|
|
"config_status": ConfigStatus.VALID.value,
|
|
"connection_status": ConnectionStatus.CONNECTED.value,
|
|
"validation_messages": "All good",
|
|
}
|
|
model = DeviceTestModel.model_validate(data)
|
|
yield model
|
|
|
|
@pytest.fixture
|
|
def validation_list_item(self, device_model, qtbot):
|
|
"""Fixture to create a ValidationListItem instance."""
|
|
colors = get_accent_colors()
|
|
icons = get_validation_icons(colors, (16, 16))
|
|
item = ValidationListItem(device_model=device_model, validation_icons=icons)
|
|
qtbot.addWidget(item)
|
|
qtbot.waitExposed(item)
|
|
yield item
|
|
|
|
def test_update_validation_status(self, validation_list_item: ValidationListItem):
|
|
"""Test updating status in ValidationListItem."""
|
|
# Update to invalid config status
|
|
validation_list_item._update_validation_status(
|
|
validation_msg="Error occurred",
|
|
config_status=ConfigStatus.INVALID.value,
|
|
connection_status=ConnectionStatus.CANNOT_CONNECT.value,
|
|
)
|
|
assert validation_list_item.device_model.config_status == ConfigStatus.INVALID.value
|
|
assert (
|
|
validation_list_item.device_model.connection_status
|
|
== ConnectionStatus.CANNOT_CONNECT.value
|
|
)
|
|
assert validation_list_item.device_model.validation_msg == "Error occurred"
|
|
|
|
def test_validation_logic(self, validation_list_item: ValidationListItem):
|
|
"""Test starting and stopping validation spinner."""
|
|
# Schedule validation
|
|
validation_list_item.validation_scheduled()
|
|
assert validation_list_item.status_button.isEnabled() is False
|
|
assert validation_list_item.connection_button.isEnabled() is False
|
|
assert validation_list_item.is_running is False
|
|
|
|
# Start validation
|
|
with mock.patch.object(validation_list_item._spinner, "start") as mock_spinner_start:
|
|
validation_list_item.start_validation()
|
|
assert validation_list_item.is_running is True
|
|
mock_spinner_start.assert_called_once()
|
|
|
|
# Finish validation
|
|
|
|
with mock.patch.object(validation_list_item._spinner, "stop") as mock_spinner_stop:
|
|
|
|
# I. successful validation
|
|
validation_list_item.on_validation_finished(
|
|
validation_msg="Finished",
|
|
config_status=ConfigStatus.VALID.value,
|
|
connection_status=ConnectionStatus.CAN_CONNECT.value,
|
|
)
|
|
assert validation_list_item.is_running is False
|
|
assert (
|
|
validation_list_item.device_model.connection_status
|
|
== ConnectionStatus.CAN_CONNECT.value
|
|
)
|
|
mock_spinner_stop.assert_called_once()
|
|
# Buttons should be disabled after validation finished good
|
|
assert validation_list_item.connection_button.isEnabled() is False
|
|
assert validation_list_item.status_button.isEnabled() is False
|
|
|
|
# Restart validation
|
|
validation_list_item.start_validation()
|
|
mock_spinner_stop.reset_mock()
|
|
|
|
# II. failed validation
|
|
validation_list_item.on_validation_finished(
|
|
validation_msg="Finished",
|
|
config_status=ConfigStatus.INVALID.value,
|
|
connection_status=ConnectionStatus.UNKNOWN.value,
|
|
)
|
|
assert validation_list_item.is_running is False
|
|
mock_spinner_stop.assert_called_once()
|
|
assert validation_list_item.connection_button.isEnabled() is True
|
|
assert validation_list_item.status_button.isEnabled() is True
|
|
|
|
####################
|
|
### OphydValidation widget tests
|
|
####################
|
|
|
|
@pytest.fixture
|
|
def device_test_runnable(self, device_model, qtbot):
|
|
"""Fixture to create a DeviceTest instance."""
|
|
widget = QtWidgets.QWidget() # Create a widget because the runnable is not a widget itself
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
widget._runnable_test = DeviceTest(
|
|
device_model=device_model, timeout=5, enable_connect=True, force_connect=False
|
|
)
|
|
yield widget
|
|
|
|
def test_device_test(self, device_test_runnable, qtbot):
|
|
"""Test DeviceTest runnable initialization."""
|
|
runnable: DeviceTest = device_test_runnable._runnable_test
|
|
assert runnable.device_config.get("name") == "test_device"
|
|
assert runnable.timeout == 5
|
|
assert runnable.enable_connect is True
|
|
assert runnable._cancelled is False
|
|
|
|
# Callback validation
|
|
container = []
|
|
|
|
def _runnable_callback(
|
|
config: dict, config_is_valid: bool, connection_status: bool, error_msg: str
|
|
):
|
|
container.append((config, config_is_valid, connection_status, error_msg))
|
|
|
|
runnable.signals.device_validated.connect(_runnable_callback)
|
|
|
|
# Callback started
|
|
started_container = []
|
|
|
|
def _runnable_started_callback():
|
|
started_container.append(True)
|
|
|
|
runnable.signals.device_validation_started.connect(_runnable_started_callback)
|
|
|
|
# Should resolve without running test if cancelled
|
|
runnable.cancel()
|
|
with qtbot.waitSignals(
|
|
[runnable.signals.device_validation_started, runnable.signals.device_validated]
|
|
):
|
|
runnable.run()
|
|
assert len(started_container) == 1
|
|
assert len(container) == 1
|
|
config, config_is_valid, connection_status, error_msg = container[0]
|
|
assert config == runnable.device_config
|
|
assert config_is_valid == ConfigStatus.UNKNOWN.value
|
|
assert connection_status == ConnectionStatus.UNKNOWN.value
|
|
assert error_msg == f"{runnable.device_config.get('name', '')} was cancelled by user."
|
|
|
|
# Now we run it without cancelling
|
|
|
|
# Reset containers
|
|
container = []
|
|
started_container = []
|
|
runnable._cancelled = False
|
|
with mock.patch.object(
|
|
runnable.tester, "run_with_list_output"
|
|
) as mock_run_with_list_output:
|
|
mock_run_with_list_output.return_value = [
|
|
TestResult(
|
|
name="test_device",
|
|
config_is_valid=ConfigStatus.VALID.value,
|
|
success=ConnectionStatus.CANNOT_CONNECT.value,
|
|
message="All good",
|
|
)
|
|
]
|
|
with qtbot.waitSignals(
|
|
[runnable.signals.device_validation_started, runnable.signals.device_validated]
|
|
):
|
|
runnable.run()
|
|
assert len(started_container) == 1
|
|
assert len(container) == 1
|
|
config, config_is_valid, connection_status, error_msg = container[0]
|
|
assert config == runnable.device_config
|
|
assert config_is_valid == ConfigStatus.VALID.value
|
|
assert connection_status == ConnectionStatus.CANNOT_CONNECT.value
|
|
assert error_msg == "All good"
|
|
|
|
@pytest.fixture
|
|
def thread_pool_manager(self, qtbot):
|
|
"""Fixture to create a ThreadPoolManager instance."""
|
|
widget = QtWidgets.QWidget() # Create a widget because the manager is not a widget itself
|
|
widget._pool_manager = ThreadPoolManager()
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
yield widget
|
|
|
|
def test_thread_pool_manager(self, thread_pool_manager):
|
|
"""Test ThreadPoolManager initialization."""
|
|
manager: ThreadPoolManager = thread_pool_manager._pool_manager
|
|
assert manager.pool.maxThreadCount() == 4
|
|
assert manager._timer.interval() == 100
|
|
|
|
# Test submitting tasks
|
|
device_test_mock_1 = mock.MagicMock()
|
|
device_test_mock_2 = mock.MagicMock()
|
|
manager.submit(device_name="test_device", device_test=device_test_mock_1)
|
|
manager.submit(device_name="test_device_2", device_test=device_test_mock_2)
|
|
assert len(manager.get_scheduled_tests()) == 2
|
|
assert len(manager.get_active_tests()) == 0
|
|
|
|
# Clear queue
|
|
manager.clear_queue()
|
|
assert device_test_mock_1.cancel.call_count == 1
|
|
assert device_test_mock_2.cancel.call_count == 1
|
|
assert device_test_mock_1.signals.device_validated.disconnect.call_count == 1
|
|
assert device_test_mock_2.signals.device_validated.disconnect.call_count == 1
|
|
assert len(manager.get_scheduled_tests()) == 0
|
|
assert len(manager.get_active_tests()) == 0
|
|
|
|
def test_thread_pool_process_queue(self, thread_pool_manager, qtbot):
|
|
"""Test ThreadPoolManager process queue logic."""
|
|
# Submit 2 elements to the queue
|
|
manager: ThreadPoolManager = thread_pool_manager._pool_manager
|
|
device_test_mock_1 = mock.MagicMock()
|
|
device_test_mock_2 = mock.MagicMock()
|
|
manager.submit(device_name="test_device", device_test=device_test_mock_1)
|
|
manager.submit(device_name="test_device_2", device_test=device_test_mock_2)
|
|
|
|
# Validations running cb
|
|
container = []
|
|
|
|
def _validations_running_cb(is_true: bool):
|
|
container.append(is_true)
|
|
|
|
manager.validations_are_running.connect(_validations_running_cb)
|
|
with mock.patch.object(manager.pool, "start") as mock_pool_start:
|
|
with qtbot.waitSignal(manager.validations_are_running):
|
|
# Process queue, should start both tasks
|
|
manager._process_queue()
|
|
assert mock_pool_start.call_count == 2
|
|
assert len(manager.get_scheduled_tests()) == 0
|
|
assert len(manager.get_active_tests()) == 2
|
|
assert len(container) == 1
|
|
assert container[0] is True
|
|
device_test_mock_1.signals.device_validated.connect.assert_called_with(
|
|
manager._on_task_finished
|
|
)
|
|
device_test_mock_2.signals.device_validated.connect.assert_called_with(
|
|
manager._on_task_finished
|
|
)
|
|
|
|
# Simulate one task finished
|
|
manager._on_task_finished({"name": "test_device"}, True, True, "All good")
|
|
assert len(manager.get_active_tests()) == 1
|
|
|
|
# Process queue again, nothing should happen as queue is empty
|
|
mock_pool_start.reset_mock()
|
|
manager._process_queue()
|
|
assert mock_pool_start.call_count == 0
|
|
assert len(manager.get_active_tests()) == 1
|
|
|
|
@pytest.fixture
|
|
def legend_label(self, qtbot):
|
|
"""Fixture to create a TestLegendLabel instance."""
|
|
label = LegendLabel()
|
|
qtbot.addWidget(label)
|
|
qtbot.waitExposed(label)
|
|
yield label
|
|
|
|
def test_legend_label(self, legend_label: LegendLabel):
|
|
"""Test LegendLabel."""
|
|
layout: QtWidgets.QGridLayout = legend_label.layout()
|
|
# Verify layout structure
|
|
assert layout.rowCount() == 2
|
|
assert layout.columnCount() == 6
|
|
# Assert labels and icons are present
|
|
label = layout.itemAtPosition(0, 0).widget()
|
|
assert label.text() == "Config Legend:"
|
|
label = layout.itemAtPosition(1, 0).widget()
|
|
assert label.text() == "Connect Legend:"
|
|
|
|
@pytest.fixture
|
|
def ophyd_test(self, qtbot):
|
|
"""Fixture to create an OphydValidation instance. We patch the method that starts the polling loop to avoid side effects."""
|
|
with (
|
|
mock.patch(
|
|
"bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._thread_pool_poll_loop",
|
|
return_value=None,
|
|
),
|
|
mock.patch(
|
|
"bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation.OphydValidation._is_device_in_redis_session",
|
|
return_value=False,
|
|
),
|
|
):
|
|
widget = OphydValidation()
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
yield widget
|
|
|
|
def test_ophyd_test_initialization(self, ophyd_test: OphydValidation, qtbot):
|
|
"""Test OphydValidation widget initialization."""
|
|
assert isinstance(ophyd_test.list_widget, BECList)
|
|
assert isinstance(ophyd_test.thread_pool_manager, ThreadPoolManager)
|
|
layout = ophyd_test.layout()
|
|
# Widget with layout + legend label
|
|
assert isinstance(layout.itemAt(1).widget(), LegendLabel)
|
|
|
|
# Test clicking the stop validation button
|
|
click_event = Event()
|
|
|
|
def _stop_validation_button_clicked():
|
|
click_event.set()
|
|
|
|
ophyd_test._stop_validation_button.clicked.connect(_stop_validation_button_clicked)
|
|
with qtbot.waitSignal(ophyd_test._stop_validation_button.clicked):
|
|
# Simulate click
|
|
qtbot.mouseClick(ophyd_test._stop_validation_button, QtCore.Qt.LeftButton)
|
|
assert click_event.is_set()
|
|
|
|
def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot):
|
|
"""Test adding devices to OphydValidation widget."""
|
|
sample_devices = [
|
|
{
|
|
"name": "motor_x",
|
|
"deviceClass": "EpicsMotor",
|
|
"readoutPriority": "baseline",
|
|
"onFailure": "retry",
|
|
"deviceTags": ["motors"],
|
|
"description": "X-axis motor",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": False,
|
|
},
|
|
{
|
|
"name": "detector_main",
|
|
"deviceClass": "ophyd.EpicsSignal",
|
|
"readoutPriority": "async",
|
|
"onFailure": "buffer",
|
|
"deviceTags": ["detectors", "main"],
|
|
"description": "Main area detector",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": True,
|
|
},
|
|
]
|
|
# Initially empty, add devices
|
|
with mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test:
|
|
ophyd_test.change_device_configs(sample_devices, added=True)
|
|
assert len(ophyd_test.get_device_configs()) == 2
|
|
|
|
# Adding again should overwrite existing ones
|
|
with mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_configs:
|
|
ophyd_test.change_device_configs(sample_devices, added=True)
|
|
assert len(ophyd_test.get_device_configs()) == 2
|
|
assert mock_remove_configs.call_count == 2 # Once for each device
|
|
|
|
# Click item in list
|
|
item = ophyd_test.list_widget.item(0)
|
|
with qtbot.waitSignal(ophyd_test.item_clicked) as blocker:
|
|
qtbot.mouseClick(
|
|
ophyd_test.list_widget.viewport(),
|
|
QtCore.Qt.LeftButton,
|
|
pos=ophyd_test.list_widget.visualItemRect(item).center(),
|
|
)
|
|
device_name = blocker.args[0]
|
|
assert (
|
|
ophyd_test.list_widget.get_widget_for_item(item).device_model.device_name
|
|
== device_name
|
|
)
|
|
|
|
# Clear running validation
|
|
with (
|
|
mock.patch.object(
|
|
ophyd_test.thread_pool_manager, "clear_device_in_queue"
|
|
) as mock_clear,
|
|
mock.patch.object(ophyd_test, "_on_device_test_completed") as mock_on_completed,
|
|
):
|
|
ophyd_test.cancel_validation("motor_x")
|
|
mock_clear.assert_called_once_with("motor_x")
|
|
mock_on_completed.assert_called_once_with(
|
|
sample_devices[0],
|
|
ConfigStatus.UNKNOWN.value,
|
|
ConnectionStatus.UNKNOWN.value,
|
|
"motor_x was cancelled by user.",
|
|
)
|
|
|
|
def test_ophyd_test_submit_test(
|
|
self, ophyd_test: OphydValidation, validation_list_item: ValidationListItem, qtbot
|
|
):
|
|
"""Test submitting a device test to the thread pool manager."""
|
|
with (
|
|
mock.patch.object(
|
|
validation_list_item, "validation_scheduled"
|
|
) as mock_validation_scheduled,
|
|
mock.patch.object(ophyd_test.thread_pool_manager, "submit") as mock_thread_pool_submit,
|
|
):
|
|
ophyd_test._submit_test(
|
|
validation_list_item, connect=True, force_connect=False, timeout=10
|
|
)
|
|
mock_validation_scheduled.assert_called_once()
|
|
mock_thread_pool_submit.assert_called_once()
|
|
|
|
mock_validation_scheduled.reset_mock()
|
|
mock_thread_pool_submit.reset_mock()
|
|
# Assume device is already in Redis
|
|
with (
|
|
mock.patch.object(ophyd_test, "_is_device_in_redis_session") as mock_in_redis,
|
|
mock.patch.object(ophyd_test, "_remove_device_config") as mock_remove_device,
|
|
):
|
|
mock_in_redis.return_value = True
|
|
with qtbot.waitSignal(ophyd_test.validation_completed) as blocker:
|
|
ophyd_test._submit_test(
|
|
validation_list_item, connect=True, force_connect=False, timeout=10
|
|
)
|
|
mock_validation_scheduled.assert_not_called()
|
|
mock_thread_pool_submit.assert_not_called()
|
|
assert validation_list_item.device_model.device_config == blocker.args[0]
|
|
assert blocker.args[1] is ConfigStatus.VALID.value
|
|
assert blocker.args[2] is ConnectionStatus.CONNECTED.value
|
|
|
|
def test_ophyd_test_compare_device_configs(self, ophyd_test: OphydValidation):
|
|
"""Test comparing device configurations."""
|
|
device_config_1 = {
|
|
"name": "motor_x",
|
|
"deviceClass": "EpicsMotor",
|
|
"readoutPriority": "baseline",
|
|
"onFailure": "retry",
|
|
"deviceTags": ["motors"],
|
|
"description": "X-axis motor",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": False,
|
|
}
|
|
device_config_2 = device_config_1.copy()
|
|
# Should be equal
|
|
assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is True
|
|
|
|
# Change a field
|
|
device_config_2["description"] = "Modified description"
|
|
assert ophyd_test._compare_device_configs(device_config_1, device_config_2) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"config_status,connection_status, msg",
|
|
[
|
|
(ConfigStatus.VALID.value, ConnectionStatus.CONNECTED.value, "Validation successful"),
|
|
(
|
|
ConfigStatus.INVALID.value,
|
|
ConnectionStatus.CANNOT_CONNECT.value,
|
|
"Validation failed",
|
|
),
|
|
],
|
|
)
|
|
def test_ophyd_test_validation_succeeds(
|
|
self, ophyd_test: OphydValidation, qtbot, config_status, connection_status, msg
|
|
):
|
|
"""Test handling of successful device validation."""
|
|
sample_device = {
|
|
"name": "motor_x",
|
|
"deviceClass": "EpicsMotor",
|
|
"readoutPriority": "baseline",
|
|
"onFailure": "retry",
|
|
"deviceTags": ["motors"],
|
|
"description": "X-axis motor",
|
|
"enabled": True,
|
|
"readOnly": False,
|
|
"softwareTrigger": False,
|
|
}
|
|
with (
|
|
mock.patch.object(ophyd_test, "__delayed_submit_test") as mock_submit_test,
|
|
mock.patch.object(ophyd_test, "_is_device_in_redis_session", return_value=False),
|
|
):
|
|
ophyd_test.change_device_configs([sample_device], added=True)
|
|
|
|
# Emit validation completed signal from thread pool manager
|
|
with qtbot.waitSignal(ophyd_test.validation_completed) as blocker:
|
|
validation_item = ophyd_test.list_widget.get_widget_for_item(
|
|
ophyd_test.list_widget.item(0)
|
|
)
|
|
with mock.patch.object(
|
|
validation_item, "on_validation_finished"
|
|
) as mock_on_validation_finished:
|
|
ophyd_test.thread_pool_manager.device_validated.emit(
|
|
sample_device, config_status, connection_status, msg
|
|
)
|
|
if config_status != ConfigStatus.VALID.value:
|
|
mock_on_validation_finished.assert_called_once_with(
|
|
validation_msg=msg,
|
|
config_status=config_status,
|
|
connection_status=connection_status,
|
|
)
|
|
|
|
assert blocker.args[0] == sample_device
|
|
assert blocker.args[1] == config_status
|
|
assert blocker.args[2] == connection_status
|
|
assert blocker.args[3] == msg
|
|
|
|
|
|
class TestDeviceConfigTemplate:
|
|
|
|
def test_try_literal_eval(self):
|
|
"""Test the _try_literal_eval static method."""
|
|
# handle booleans
|
|
assert _try_literal_eval("True") is True
|
|
assert _try_literal_eval("False") is False
|
|
assert _try_literal_eval("true") is True
|
|
assert _try_literal_eval("false") is False
|
|
# handle empty string
|
|
assert _try_literal_eval("") == ""
|
|
# Lists
|
|
assert _try_literal_eval([0, 1, 2]) == [0, 1, 2]
|
|
# Set and tuples
|
|
assert _try_literal_eval((1, 2, 3)) == (1, 2, 3)
|
|
# Numbers int and float
|
|
assert _try_literal_eval("123") == 123
|
|
assert _try_literal_eval("45.67") == 45.67
|
|
# if literal eval fails, return original string
|
|
assert _try_literal_eval(" invalid text,,, ") == " invalid text,,, "
|
|
|
|
def _create_widget_for_device_field(self, field_name: str, qtbot) -> QtWidgets.QWidget:
|
|
"""Helper method to create a widget for a given device field."""
|
|
field = DEVICE_FIELDS[field_name]
|
|
widget = field.widget_cls()
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
return widget
|
|
|
|
def test_device_fields_name(self, qtbot):
|
|
"""Test DEVICE_FIELDS content for 'name' field."""
|
|
colors = get_accent_colors()
|
|
name_field: DeviceConfigField = DEVICE_FIELDS["name"]
|
|
assert name_field.label == "Name"
|
|
assert name_field.widget_cls == InputLineEdit
|
|
assert name_field.required is True
|
|
# Create widget and test
|
|
widget: InputLineEdit = self._create_widget_for_device_field("name", qtbot)
|
|
if name_field.validation_callback is not None:
|
|
for cb in name_field.validation_callback:
|
|
widget.register_validation_callback(cb)
|
|
# Empty input is invalid
|
|
assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};"
|
|
# Valid input
|
|
with qtbot.waitSignal(widget.textChanged):
|
|
widget.setText("valid_device_name")
|
|
assert widget.styleSheet() == ""
|
|
# InValid input
|
|
with qtbot.waitSignal(widget.textChanged):
|
|
widget.setText("invalid _name")
|
|
assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};"
|
|
|
|
def test_device_fields_device_class(self, qtbot):
|
|
"""Test DEVICE_FIELDS content for 'deviceClass' field."""
|
|
colors = get_accent_colors()
|
|
device_class_field: DeviceConfigField = DEVICE_FIELDS["deviceClass"]
|
|
assert device_class_field.label == "Device Class"
|
|
assert device_class_field.widget_cls == InputLineEdit
|
|
assert device_class_field.required is True
|
|
# Create widget and test
|
|
widget: InputLineEdit = self._create_widget_for_device_field("deviceClass", qtbot)
|
|
if device_class_field.validation_callback is not None:
|
|
for cb in device_class_field.validation_callback:
|
|
widget.register_validation_callback(cb)
|
|
# Empty input is invalid
|
|
assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};"
|
|
# Valid input
|
|
with qtbot.waitSignal(widget.textChanged):
|
|
widget.setText("EpicsMotor")
|
|
assert widget.styleSheet() == ""
|
|
# InValid input
|
|
with qtbot.waitSignal(widget.textChanged):
|
|
widget.setText("wrlong-sadnjkas:'&")
|
|
assert widget.styleSheet() == f"border: 1px solid {colors.emergency.name()};"
|
|
|
|
def test_device_fields_description(self, qtbot):
|
|
"""Test DEVICE_FIELDS content for 'description' field."""
|
|
description_field: DeviceConfigField = DEVICE_FIELDS["description"]
|
|
assert description_field.label == "Description"
|
|
assert description_field.widget_cls == QtWidgets.QTextEdit
|
|
assert description_field.required is False
|
|
assert description_field.placeholder_text == "Short device description"
|
|
# Create widget and test
|
|
widget: QtWidgets.QTextEdit = self._create_widget_for_device_field("description", qtbot)
|
|
|
|
def test_device_fields_toggle_fields(self, qtbot):
|
|
"""Test DEVICE_FIELDS content for 'enabled' and 'readOnly' fields."""
|
|
for field_name in ["enabled", "readOnly", "softwareTrigger"]:
|
|
field: DeviceConfigField = DEVICE_FIELDS[field_name]
|
|
assert field.label in ["Enabled", "Read Only", "Software Trigger"]
|
|
assert field.widget_cls == ToggleSwitch
|
|
assert field.required is False
|
|
if field_name == "enabled":
|
|
assert field.default is True
|
|
else:
|
|
assert field.default is False
|
|
|
|
@pytest.fixture
|
|
def device_config_template(self, qtbot):
|
|
"""Fixture to create a DeviceConfigTemplate instance."""
|
|
template = DeviceConfigTemplate()
|
|
qtbot.addWidget(template)
|
|
qtbot.waitExposed(template)
|
|
yield template
|
|
|
|
def test_device_config_teamplate_default_init(
|
|
self, device_config_template: DeviceConfigTemplate, qtbot
|
|
):
|
|
"""Test DeviceConfigTemplate default initialization."""
|
|
assert (
|
|
device_config_template.template
|
|
== OPHYD_DEVICE_TEMPLATES["CustomDevice"]["CustomDevice"]
|
|
)
|
|
|
|
# Check settings box, should have 3 labels, 2 InputLineEdit, 1 QTextEdit
|
|
assert len(device_config_template.settings_box.findChildren(QtWidgets.QLabel)) == 3
|
|
assert len(device_config_template.settings_box.findChildren(InputLineEdit)) == 2
|
|
assert len(device_config_template.settings_box.findChildren(QtWidgets.QTextEdit)) == 1
|
|
|
|
# Check advanced control box, should have 5 labels for
|
|
# readoutPriority, onFailure, enabled, readOnly, softwareTrigger
|
|
assert len(device_config_template.advanced_control_box.findChildren(QtWidgets.QLabel)) == 5
|
|
assert len(device_config_template.advanced_control_box.findChildren(ToggleSwitch)) == 3
|
|
assert (
|
|
len(device_config_template.advanced_control_box.findChildren(ReadoutPriorityComboBox))
|
|
== 1
|
|
)
|
|
assert len(device_config_template.advanced_control_box.findChildren(OnFailureComboBox)) == 1
|
|
|
|
# Check connection box for CustomDevice, should be empty dict.
|
|
assert isinstance(
|
|
device_config_template.connection_settings_box.layout().itemAt(0).widget(),
|
|
ParameterValueWidget,
|
|
)
|
|
|
|
# Check additional settings box for CustomDevice, should be empty dict.
|
|
tool_box = device_config_template.additional_settings_box.layout().itemAt(0).widget()
|
|
assert isinstance(tool_box, QtWidgets.QToolBox)
|
|
assert isinstance(device_config_template._widgets["userParameter"], ParameterValueWidget)
|
|
assert isinstance(device_config_template._widgets["deviceTags"], DeviceTagsWidget)
|
|
|
|
# Check default values and proper widgets in _widgets dict
|
|
for field_name, widget in device_config_template._widgets.items():
|
|
if field_name == "deviceConfig":
|
|
assert isinstance(widget, ParameterValueWidget)
|
|
assert widget.parameters() == {} # Default empty dict for CustomDevice template
|
|
continue
|
|
assert field_name in DEVICE_FIELDS
|
|
field = DEVICE_FIELDS[field_name]
|
|
assert isinstance(widget, field.widget_cls)
|
|
# Check default values
|
|
if field.default is not None:
|
|
if isinstance(widget, InputLineEdit):
|
|
assert widget.text() == str(field.default)
|
|
elif isinstance(widget, ToggleSwitch):
|
|
assert widget.isChecked() == field.default
|
|
elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)):
|
|
assert widget.currentText() == field.default
|
|
|
|
def test_device_config_template_epics_motor(
|
|
self, device_config_template: DeviceConfigTemplate, qtbot
|
|
):
|
|
"""Test the DeviceConfigTemplate for the EpicsMotor device class."""
|
|
device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"])
|
|
|
|
# Check that all widgets are created properly
|
|
for field_name, widget in device_config_template._widgets.items():
|
|
if field_name == "deviceConfig":
|
|
for sub_field, sub_widget in widget.items():
|
|
if sub_field in DEVICE_CONFIG_FIELDS:
|
|
field = DEVICE_CONFIG_FIELDS[sub_field]
|
|
assert isinstance(sub_widget, field.widget_cls)
|
|
if sub_field == "limits":
|
|
# Limits is LimitInputWidget
|
|
sub_widget: LimitInputWidget
|
|
assert sub_widget.get_limits() == [0, 0] # Default limits
|
|
else:
|
|
assert isinstance(widget, InputLineEdit)
|
|
continue
|
|
assert field_name in DEVICE_FIELDS
|
|
field = DEVICE_FIELDS[field_name]
|
|
assert isinstance(widget, field.widget_cls)
|
|
# Check default values
|
|
if field.default is not None:
|
|
if isinstance(widget, InputLineEdit):
|
|
assert widget.text() == str(field.default)
|
|
elif isinstance(widget, ToggleSwitch):
|
|
assert widget.isChecked() == field.default
|
|
elif isinstance(widget, (ReadoutPriorityComboBox, OnFailureComboBox)):
|
|
assert widget.currentText() == field.default
|
|
|
|
def test_device_config_template_get_set_config(
|
|
self, device_config_template: DeviceConfigTemplate, qtbot
|
|
):
|
|
# Test get config for default Custom Device template
|
|
device_config_template.change_template(OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"])
|
|
config = device_config_template.get_config_fields()
|
|
for k, v in OPHYD_DEVICE_TEMPLATES["EpicsMotor"]["EpicsMotor"].items():
|
|
if k == "deviceConfig":
|
|
v: EpicsMotorDeviceConfigTemplate
|
|
v.model_validate(config["deviceConfig"])
|
|
continue
|
|
if isinstance(v, (list, tuple)):
|
|
v = tuple(v)
|
|
config_value = config[k]
|
|
if isinstance(config_value, (list, tuple)):
|
|
config_value = tuple(config_value)
|
|
assert config_value == v
|
|
|
|
# Set config from Model for custom EpicsMotor
|
|
model = DeviceModel(
|
|
name="motor_x",
|
|
deviceClass="ophyd.EpicsMotor",
|
|
readoutPriority="baseline",
|
|
enabled=False,
|
|
deviceConfig={"prefix": "MOTOR_X:", "limits": [-10, 10], "additional_field": 42},
|
|
deviceTags=["motors", "x_axis"],
|
|
userParameter={"param1": 100, "param2": "value2"},
|
|
)
|
|
device_config_template.set_config_fields(model.model_dump())
|
|
# Check config
|
|
config = device_config_template.get_config_fields()
|
|
assert config["name"] == "motor_x"
|
|
assert config["deviceClass"] == "ophyd.EpicsMotor"
|
|
assert config["readoutPriority"] == "baseline"
|
|
assert config["enabled"] is False
|
|
assert config["deviceConfig"] == {
|
|
"prefix": "MOTOR_X:",
|
|
"limits": [-10, 10],
|
|
"additional_field": 42,
|
|
}
|
|
assert set(config["deviceTags"]) == {"motors", "x_axis"}
|
|
assert config["userParameter"] == {"param1": 100, "param2": "value2"}
|
|
|
|
def test_limit_input_widget(self, qtbot):
|
|
"""Test LimitInputWidget functionality."""
|
|
colors = get_accent_colors()
|
|
widget = LimitInputWidget()
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
|
|
# Default limits should be [0, 0]
|
|
assert widget.get_limits() == [0, 0]
|
|
|
|
assert widget._is_valid_limit() is True
|
|
assert widget.enable_toggle.isChecked() is False
|
|
|
|
# Set limits externally
|
|
widget.set_limits([-5, 5])
|
|
assert widget.get_limits() == [-5, 5]
|
|
assert widget._is_valid_limit() is True
|
|
assert widget.enable_toggle.isChecked() is False
|
|
|
|
# Enable toggle
|
|
with qtbot.waitSignal(widget.enable_toggle.stateChanged):
|
|
widget.enable_toggle.setChecked(True)
|
|
|
|
assert widget.enable_toggle.isChecked() is True
|
|
# Set invalid limits (min >= max)
|
|
with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]):
|
|
widget.min_input.setValue(2)
|
|
widget.max_input.setValue(1)
|
|
|
|
assert widget._is_valid_limit() is False
|
|
assert widget.min_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};"
|
|
assert widget.max_input.styleSheet() == f"border: 1px solid {colors.emergency.name()};"
|
|
|
|
# Reset to default values
|
|
with qtbot.waitSignals([widget.min_input.valueChanged, widget.max_input.valueChanged]):
|
|
widget.min_input.setValue(0)
|
|
widget.max_input.setValue(0)
|
|
|
|
assert widget.get_limits() == [0, 0]
|
|
assert widget.min_input.styleSheet() == ""
|
|
assert widget.max_input.styleSheet() == ""
|
|
|
|
def test_parameter_value_widget(self, qtbot):
|
|
"""Test ParameterValueWidget functionality."""
|
|
widget = ParameterValueWidget()
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
|
|
# Initially no parameters
|
|
assert widget.parameters() == {}
|
|
|
|
# Add parameters
|
|
sample_params = {"param1": 10, "param2": "value", "param3": True}
|
|
for k, v in sample_params.items():
|
|
widget.add_parameter_line(k, v)
|
|
assert widget.parameters() == sample_params
|
|
|
|
# Modify a parameter
|
|
param1_widget: InputLineEdit = widget.tree_widget.itemWidget(
|
|
widget.tree_widget.topLevelItem(0), 1
|
|
)
|
|
with qtbot.waitSignal(param1_widget.textChanged):
|
|
param1_widget.setText("20")
|
|
updated_params = widget.parameters()
|
|
assert updated_params["param1"] == 20
|
|
assert updated_params["param2"] == "value"
|
|
assert updated_params["param3"] is True
|
|
# Select top item
|
|
widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0))
|
|
widget.remove_parameter_line()
|
|
# Check that param1 is removed
|
|
assert widget.parameters() == {"param2": "value", "param3": True}
|
|
# Clear all parameters
|
|
widget.clear_widget()
|
|
assert widget.parameters() == {}
|
|
|
|
def test_device_tags_widget(self, qtbot):
|
|
"""Test DeviceTagsWidget functionality."""
|
|
widget = DeviceTagsWidget()
|
|
qtbot.addWidget(widget)
|
|
qtbot.waitExposed(widget)
|
|
|
|
# Initially no tags
|
|
assert widget.parameters() == []
|
|
|
|
# Add tags
|
|
with qtbot.waitSignal(widget._button_add.clicked):
|
|
qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton)
|
|
qtbot.mouseClick(widget._button_add, QtCore.Qt.LeftButton)
|
|
qtbot.wait(200) # wait item to be added to tree widget
|
|
|
|
assert widget.tree_widget.topLevelItemCount() == 2
|
|
assert widget.parameters() == [] # No value yet means no parameters
|
|
|
|
# set tag text
|
|
widget_item = widget.tree_widget.topLevelItem(0)
|
|
tag_widget: InputLineEdit = widget.tree_widget.itemWidget(widget_item, 0)
|
|
with qtbot.waitSignal(tag_widget.textChanged):
|
|
tag_widget.setText("motor")
|
|
assert widget.parameters() == ["motor"]
|
|
|
|
# Remove tag
|
|
widget.tree_widget.setCurrentItem(widget.tree_widget.topLevelItem(0))
|
|
with qtbot.waitSignal(widget._button_remove.clicked):
|
|
qtbot.mouseClick(widget._button_remove, QtCore.Qt.LeftButton)
|
|
qtbot.wait(200) # wait item to be added to tree widget
|
|
|
|
assert widget.tree_widget.topLevelItemCount() == 1
|
|
|
|
# Clear all tags
|
|
widget.clear_widget()
|
|
assert widget.tree_widget.topLevelItemCount() == 0
|