feat(dm-view): initial device manager view added

This commit is contained in:
2025-08-22 07:55:33 +02:00
committed by wyzula-jan
parent fc4ad051f8
commit 5d0ec2186b
36 changed files with 4995 additions and 552 deletions
+17 -17
View File
@@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client):
yield dev_browser
def test_device_browser_init_with_devices(device_browser):
def test_device_browser_init_with_devices(device_browser: DeviceBrowser):
"""
Test that the device browser is initialized with the correct number of devices.
"""
device_list = device_browser.ui.device_list
device_list = device_browser.dev_list
assert device_list.count() == len(device_browser.dev)
@@ -58,11 +58,11 @@ def test_device_browser_filtering(
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
def num_visible(item_dict):
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values())))
device_browser.ui.filter_input.setText(search_term)
qtbot.wait(100)
assert num_visible(device_browser._device_items) == expected
assert num_visible(device_browser.dev_list._item_dict) == expected
def test_device_item_mouse_press_event(device_browser, qtbot):
@@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
Test that the mousePressEvent is triggered correctly.
"""
# Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
@@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot):
Test that the form is displayed when the item is expanded, and that the expansion is triggered
by clicking on the expansion button, the title, or the device icon
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
@@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot):
form = tab_widget.widget(0).layout().itemAt(0).widget()
assert widget.expanded
assert (name_field := form.widget_dict.get("name")) is not None
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)
qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
assert not widget.expanded
@@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
"""
Test that the mousePressEvent is triggered correctly and initiates a drag.
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
device_name = widget.device
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
@@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot):
Test that the mouseDoubleClickEvent is triggered correctly.
"""
# Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
qtbot.mouseDClick(widget, Qt.LeftButton)
def test_device_deletion(device_browser, qtbot):
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
widget._config_helper = mock.MagicMock()
assert widget.device in device_browser._device_items
assert widget.device in device_browser.dev_list._item_dict
qtbot.mouseClick(widget.delete_button, Qt.LeftButton)
qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000)
qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000)
def test_signal_display(mocked_client, qtbot):
@@ -6,7 +6,7 @@ from qtpy.QtWidgets import QDialogButtonBox, QPushButton
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
DirectUpdateDeviceConfigDialog,
_try_literal_eval,
)
@@ -29,7 +29,7 @@ def mock_client():
@pytest.fixture
def update_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
update_dialog = DeviceConfigDialog(
update_dialog = DirectUpdateDeviceConfigDialog(
device="test_device", config_helper=MagicMock(), client=mock_client
)
qtbot.addWidget(update_dialog)
@@ -39,7 +39,7 @@ def update_dialog(mock_client, qtbot):
@pytest.fixture
def add_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
add_dialog = DeviceConfigDialog(
add_dialog = DirectUpdateDeviceConfigDialog(
device=None, config_helper=MagicMock(), client=mock_client, action="add"
)
qtbot.addWidget(add_dialog)
+5 -1
View File
@@ -43,7 +43,7 @@ def test_device_input_base_init(device_input_base):
assert device_input_base.devices == []
def test_device_input_base_init_with_config(mocked_client):
def test_device_input_base_init_with_config(qtbot, mocked_client):
"""Test init with Config"""
config = {
"widget_class": "DeviceInputWidget",
@@ -55,6 +55,10 @@ def test_device_input_base_init_with_config(mocked_client):
widget2 = DeviceInputWidget(
client=mocked_client, config=DeviceInputConfig.model_validate(config)
)
qtbot.addWidget(widget)
qtbot.addWidget(widget2)
qtbot.waitExposed(widget)
qtbot.waitExposed(widget2)
for w in [widget, widget2]:
assert w.config.gui_id == "test_gui_id"
assert w.config.device_filter == ["Positioner"]
@@ -0,0 +1,869 @@
"""Unit tests for device_manager_components module."""
from unittest import mock
import pytest
import yaml
from bec_lib.atlas_models import Device as DeviceModel
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD
from bec_widgets.widgets.control.device_manager.components.device_table_view import (
USER_CHECK_DATA_ROLE,
BECTableView,
CenterCheckBoxDelegate,
CustomDisplayDelegate,
DeviceFilterProxyModel,
DeviceTableModel,
DeviceTableView,
DeviceValidatedDelegate,
DictToolTipDelegate,
WrappingTextDelegate,
)
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import (
DocstringView,
docstring_to_markdown,
)
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
### Constants ####
def test_constants_headers_help_md():
"""Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format."""
assert isinstance(HEADERS_HELP_MD, dict)
expected_keys = {
"status",
"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, str)
assert value.startswith("## ") # Each entry should start with a markdown header
### DM Docstring View ####
@pytest.fixture
def docstring_view(qtbot):
"""Fixture to create a DocstringView instance."""
view = DocstringView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
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.
"""
def test_docstring_view_docstring_to_markdown():
"""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_docstring_view_on_select_config(docstring_view):
"""Test the DocstringView on_select_config method. Called with single and multiple devices."""
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([{"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(
[{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}]
)
mock_set_device_class.assert_not_called()
mock_set_text.assert_called_once_with("")
def test_docstring_view_set_device_class(docstring_view):
"""Test the DocstringView set_device_class method with valid and invalid class names."""
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
#### DM Config View ####
@pytest.fixture
def dm_config_view(qtbot):
"""Fixture to create a DMConfigView instance."""
view = DMConfigView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
def test_dm_config_view_initialization(dm_config_view):
"""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 single device to show config"
# Check that overlay is initially shown
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
def test_dm_config_view_on_select_config(dm_config_view):
"""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
### Device Table View ####
# Not sure how to nicely test the delegates.
@pytest.fixture
def mock_table_view(qtbot):
"""Create a mock table view for delegate testing."""
table = BECTableView()
qtbot.addWidget(table)
qtbot.waitExposed(table)
yield table
@pytest.fixture
def device_table_model(qtbot, mock_table_view):
"""Fixture to create a DeviceTableModel instance."""
model = DeviceTableModel(mock_table_view)
yield model
@pytest.fixture
def device_proxy_model(qtbot, mock_table_view, device_table_model):
"""Fixture to create a DeviceFilterProxyModel instance."""
model = DeviceFilterProxyModel(mock_table_view)
model.setSourceModel(device_table_model)
mock_table_view.setModel(model)
yield model
@pytest.fixture
def qevent_mock() -> QtCore.QEvent:
"""Create a mock QEvent for testing."""
event = mock.MagicMock(spec=QtCore.QEvent)
yield event
@pytest.fixture
def view_mock() -> QtWidgets.QAbstractItemView:
"""Create a mock QAbstractItemView for testing."""
view = mock.MagicMock(spec=QtWidgets.QAbstractItemView)
yield view
@pytest.fixture
def index_mock(device_proxy_model) -> QtCore.QModelIndex:
"""Create a mock QModelIndex for testing."""
index = mock.MagicMock(spec=QtCore.QModelIndex)
index.model.return_value = device_proxy_model
yield index
@pytest.fixture
def option_mock() -> QtWidgets.QStyleOptionViewItem:
"""Create a mock QStyleOptionViewItem for testing."""
option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem)
yield option
@pytest.fixture
def painter_mock() -> QtGui.QPainter:
"""Create a mock QPainter for testing."""
painter = mock.MagicMock(spec=QtGui.QPainter)
yield painter
def test_tooltip_delegate(
mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model
):
"""Test DictToolTipDelegate tooltip generation."""
# No ToolTip event
delegate = DictToolTipDelegate(mock_table_view)
qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel
# nothing should happen
with mock.patch.object(
QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False
) as super_mock:
result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock)
super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock)
assert result is False
# ToolTip event
qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip
qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20))
source_model = device_proxy_model.sourceModel()
with (
mock.patch.object(
source_model, "get_row_data", return_value={"description": "Mock description"}
),
mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock),
mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock,
):
result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock)
show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock)
assert result is True
def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock):
"""Test CustomDisplayDelegate initialization."""
delegate = CustomDisplayDelegate(mock_table_view)
# Test _test_custom_paint, with None and a value
def _return_data():
yield None
yield "Test Value"
proxy_model = index_mock.model()
with (
mock.patch.object(proxy_model, "data", side_effect=_return_data()),
mock.patch.object(
QtWidgets.QStyledItemDelegate, "paint", return_value=None
) as super_paint_mock,
mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock,
):
delegate.paint(painter_mock, option_mock, index_mock)
super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock)
custom_paint_mock.assert_not_called()
# Call again for the value case
delegate.paint(painter_mock, option_mock, index_mock)
super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock)
assert super_paint_mock.call_count == 2
custom_paint_mock.assert_called_once_with(
painter_mock, option_mock, index_mock, "Test Value"
)
def test_center_checkbox_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test CenterCheckBoxDelegate initialization."""
delegate = CenterCheckBoxDelegate(mock_table_view)
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked)
# Check that the checkbox is centered
pixrect = delegate._icon_checked.rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked)
model = index_mock.model()
# Editor event with non-check state role
qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange
assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock)
# Editor event with check state role but not mouse button event
qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease
with (
mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked),
mock.patch.object(model, "setData") as mock_model_set,
):
delegate.editorEvent(qevent_mock, model, option_mock, index_mock)
mock_model_set.assert_called_once_with(
index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE
)
def test_device_validated_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test DeviceValidatedDelegate initialization."""
# Invalid value
delegate = DeviceValidatedDelegate(mock_table_view)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value")
painter_mock.drawPixmap.assert_not_called()
# Valid value
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value)
icon = delegate._icons[ValidationStatus.VALID.value]
pixrect = icon.rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon)
def test_wrapping_text_delegate_do_custom_paint(
mock_table_view, painter_mock, option_mock, index_mock
):
"""Test WrappingTextDelegate _do_custom_paint method."""
delegate = WrappingTextDelegate(mock_table_view)
# First case, empty text, nothing should happen
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "")
painter_mock.setPen.assert_not_called()
layout_mock = mock.MagicMock()
def _layout_comput_return(*args, **kwargs):
return layout_mock
layout_mock.draw.return_value = None
with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return):
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring")
layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft())
TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20)
TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate."
def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock):
"""Test WrappingTextDelegate _compute_layout method."""
delegate = WrappingTextDelegate(mock_table_view)
layout_mock = mock.MagicMock(spec=QtGui.QTextLayout)
# This combination should yield 4 lines
with mock.patch.object(delegate, "_get_layout", return_value=layout_mock):
layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine)
mock_line.height.return_value = 10
mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False])
option_mock.rect = TEST_RECT_FOR
option_mock.font = QtGui.QFont()
layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock)
assert layout.createLine.call_count == 4 # pylint: disable=E1101
assert mock_line.setPosition.call_count == 3
assert mock_line.setPosition.call_args_list[-1] == mock.call(
QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit
)
def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock):
"""Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines."""
delegate = WrappingTextDelegate(mock_table_view)
assert delegate.margin == 6
with (
mock.patch.object(mock_table_view, "initViewItemOption"),
mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]),
mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]),
):
# Test with empty text, should return height + 2*margin
index_mock.data.return_value = ""
option_mock.rect = TEST_RECT_FOR
font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont())
size = delegate.sizeHint(option_mock, index_mock)
assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin)
# Now test with the text that should wrap to 4 lines
index_mock.data.return_value = TEST_TEXT_WITH_4_LINES
size = delegate.sizeHint(option_mock, index_mock)
# The estimate goes to 5 lines + 2* margin
expected_lines = 5
assert size == QtCore.QSize(
100, font_metrics.height() * expected_lines + 2 * delegate.margin
)
def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model):
"""Test WrappingTextDelegate update_row_heights method."""
device_cfg = DeviceModel(
name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline"
).model_dump()
# Add single device to config
delegate = WrappingTextDelegate(mock_table_view)
row_heights = [25, 40]
with mock.patch.object(
delegate,
"sizeHint",
side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])],
):
mock_table_view.setItemDelegateForColumn(5, delegate)
mock_table_view.setItemDelegateForColumn(6, delegate)
device_proxy_model.sourceModel().set_device_config([device_cfg])
assert delegate._wrapping_text_columns is None
assert mock_table_view.rowHeight(0) == 30 # Default height
delegate._update_row_heights()
assert delegate._wrapping_text_columns == [5, 6]
assert mock_table_view.rowHeight(0) == max(row_heights)
def test_device_validation_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test DeviceValidatedDelegate initialization."""
delegate = DeviceValidatedDelegate(mock_table_view)
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID)
# Check that the checkbox is centered
pixrect = delegate._icons[ValidationStatus.VALID.value].rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(
pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value]
)
# Should not be called if invalid value
delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10)
# Check that the checkbox is centered
assert painter_mock.drawPixmap.call_count == 1
###
# Test DeviceTableModel & DeviceFilterProxyModel
###
def test_device_table_model_data(device_proxy_model):
"""Test the device table model data retrieval."""
source_model = device_proxy_model.sourceModel()
test_device = {
"status": ValidationStatus.PENDING,
"name": "test_device",
"deviceClass": "TestClass",
"readoutPriority": "baseline",
"onFailure": "retry",
"enabled": True,
"readOnly": False,
"softwareTrigger": True,
"deviceTags": ["tag1", "tag2"],
"description": "Test device",
}
source_model.add_device_configs([test_device])
assert source_model.rowCount() == 1
assert source_model.columnCount() == 10
# Check data retrieval for each column
expected_data = {
0: ValidationStatus.PENDING, # Default status
1: "test_device", # name
2: "TestClass", # deviceClass
3: "baseline", # readoutPriority
4: "retry", # onFailure
5: "tag1, tag2", # deviceTags
6: "Test device", # description
7: True, # enabled
8: False, # readOnly
9: True, # softwareTrigger
}
for col, expected in expected_data.items():
index = source_model.index(0, col)
data = source_model.data(index, QtCore.Qt.DisplayRole)
assert data == expected
def test_device_table_model_with_data(device_table_model, device_proxy_model):
"""Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data."""
# Create 3 test devices - names NOT alphabetically sorted
test_devices = [
{
"name": "zebra_device",
"deviceClass": "TestClass1",
"enabled": True,
"readOnly": False,
"readoutPriority": "baseline",
"deviceTags": ["tag1", "tag2"],
"description": "Test device Z",
},
{
"name": "alpha_device",
"deviceClass": "TestClass2",
"enabled": False,
"readOnly": True,
"readoutPriority": "primary",
"deviceTags": ["tag3"],
"description": "Test device A",
},
{
"name": "beta_device",
"deviceClass": "TestClass3",
"enabled": True,
"readOnly": False,
"readoutPriority": "secondary",
"deviceTags": [],
"description": "Test device B",
},
]
# Add devices to source model
device_table_model.add_device_configs(test_devices)
# Check source model has 3 rows and proper columns
assert device_table_model.rowCount() == 3
assert device_table_model.columnCount() == 10
# Check proxy model propagates the data
assert device_proxy_model.rowCount() == 3
assert device_proxy_model.columnCount() == 10
# Verify data propagation through proxy - check names in original order
for i, expected_device in enumerate(test_devices):
proxy_index = device_proxy_model.index(i, 1) # Column 1 is name
source_index = device_proxy_model.mapToSource(proxy_index)
source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole)
assert source_data == expected_device["name"]
# Check proxy data matches source
proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole)
assert proxy_data == source_data
# Verify all columns are accessible
headers = device_table_model.headers
for col, header in enumerate(headers):
header_data = device_table_model.headerData(
col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole
)
assert header_data is not None
def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model):
"""Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort."""
# Use same test data as above - zebra, alpha, beta (not alphabetically sorted)
test_devices = [
{
"status": ValidationStatus.VALID,
"name": "zebra_device",
"deviceClass": "TestClass1",
"enabled": True,
},
{
"status": ValidationStatus.PENDING,
"name": "alpha_device",
"deviceClass": "TestClass2",
"enabled": False,
},
{
"status": ValidationStatus.FAILED,
"name": "beta_device",
"deviceClass": "TestClass3",
"enabled": True,
},
]
device_table_model.add_device_configs(test_devices)
# Verify initial order (unsorted)
assert (
device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole)
== "zebra_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole)
== "alpha_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole)
== "beta_device"
)
# Enable sorting and sort by name column (column 1)
mock_table_view.setSortingEnabled(True)
# header = mock_table_view.horizontalHeader()
# qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton)
device_proxy_model.sort(1, QtCore.Qt.AscendingOrder)
# After sorting, verify alphabetical order: alpha, beta, zebra
assert (
device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole)
== "alpha_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole)
== "beta_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole)
== "zebra_device"
)
def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model):
"""Test (C): Remove rows from BECTableView and verify propagation."""
# Set up test data
test_devices = [
{"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True},
{"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False},
{"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True},
]
device_table_model.add_device_configs(test_devices)
assert device_table_model.rowCount() == 3
assert device_proxy_model.rowCount() == 3
# Mock the confirmation dialog to first cancel, then confirm
with mock.patch.object(
mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True]
) as mock_confirm:
# Create mock selection for middle device (device_to_remove at row 1)
selection_model = mock.MagicMock()
proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column
selection_model.selectedRows.return_value = [proxy_index_to_remove]
mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model)
# Verify the device we're about to remove
device_name_to_remove = device_proxy_model.data(
device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole
)
assert device_name_to_remove == "device_to_remove"
# Call delete_selected method
mock_table_view.delete_selected()
# Verify confirmation was called
mock_confirm.assert_called_once()
assert device_table_model.rowCount() == 3 # No change on first call
assert device_proxy_model.rowCount() == 3
# Call delete_selected again, this time it should confirm
mock_table_view.delete_selected()
# Check that the device was removed from source model
assert device_table_model.rowCount() == 2
assert device_proxy_model.rowCount() == 2
# Verify the remaining devices are correct
remaining_names = []
for i in range(device_proxy_model.rowCount()):
name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole)
remaining_names.append(name)
assert "device_to_remove" not in remaining_names
def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model):
"""Test DeviceFilterProxyModel text filtering functionality."""
# Set up test data with different device names and classes
test_devices = [
{"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"},
{"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"},
{"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"},
]
device_table_model.add_device_configs(test_devices)
assert device_proxy_model.rowCount() == 3
# Test filtering by name
device_proxy_model.setFilterText("motor")
assert device_proxy_model.rowCount() == 2
# Should show 2 rows (motor_x and motor_y)
visible_count = 0
for i in range(device_proxy_model.rowCount()):
if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()):
continue
visible_count += 1
# Test filtering by device class
device_proxy_model.setFilterText("EpicsDetector")
# Should show 1 row (detector_main)
detector_visible = False
assert device_proxy_model.rowCount() == 1
for i in range(device_table_model.rowCount()):
if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()):
source_index = device_table_model.index(i, 1) # Name column
name = device_table_model.data(source_index, QtCore.Qt.DisplayRole)
if name == "detector_main":
detector_visible = True
break
assert detector_visible
# Clear filter
device_proxy_model.setFilterText("")
assert device_proxy_model.rowCount() == 3
# Should show all 3 rows again
all_visible = all(
device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex())
for i in range(device_table_model.rowCount())
)
assert all_visible
###
# Test DeviceTableView
###
@pytest.fixture
def device_table_view(qtbot):
"""Fixture to create a DeviceTableView instance."""
view = DeviceTableView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
def test_device_table_view_initialization(qtbot, device_table_view):
"""Test the DeviceTableView search method."""
# Check that the search input fields are properly initialized and connected
qtbot.keyClicks(device_table_view.search_input, "zebra")
qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000)
qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000)
# Check table setup
# header
header = device_table_view.table.horizontalHeader()
assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags
assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description
# table selection
assert (
device_table_view.table.selectionBehavior()
== QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
)
assert (
device_table_view.table.selectionMode()
== QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection
)
def test_device_table_theme_update(device_table_view):
"""Test DeviceTableView apply_theme method."""
# Check apply theme propagates
with (
mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply,
mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated,
):
device_table_view.apply_theme("dark")
mock_apply.assert_called_once_with("dark")
mock_validated.assert_called_once_with("dark")
def test_device_table_view_updates(device_table_view):
"""Test DeviceTableView methods that update the view and model."""
# Test theme update triggered..
cfgs = [
{"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True},
{"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False},
{"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True},
]
with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize:
# Should be called once for rowsInserted
device_table_view.set_device_config(cfgs)
assert device_table_view.get_device_config() == cfgs
mock_autosize.assert_called_once()
# Update validation status, should be called again
device_table_view.update_device_validation("test_device", ValidationStatus.VALID)
assert mock_autosize.call_count == 2
# Remove a device, should triggere also a _request_autosize_columns call
device_table_view.remove_device_configs([cfgs[0]])
assert device_table_view.get_device_config() == cfgs[1:]
assert mock_autosize.call_count == 3
# Remove one device manually
device_table_view.remove_device("another_device") # Should remove the last device
assert device_table_view.get_device_config() == cfgs[2:]
assert mock_autosize.call_count == 4
# Reset the model should call it once again
device_table_view.clear_device_configs()
assert mock_autosize.call_count == 5
assert device_table_view.get_device_config() == []
def test_device_table_view_get_help_md(device_table_view):
"""Test DeviceTableView get_help_md method."""
with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at:
mock_index_at.isValid = mock.MagicMock(return_value=True)
with mock.patch.object(device_table_view, "_model") as mock_model:
mock_model.headerData = mock.MagicMock(side_effect=["softTrig"])
# Second call is True, should return the corresponding help md
assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"]
@@ -0,0 +1,224 @@
"""Unit tests for the device manager view"""
# pylint: disable=protected-access,redefined-outer-name
from unittest import mock
import pytest
from qtpy import QtCore
from qtpy.QtWidgets import QFileDialog, QMessageBox
from bec_widgets.applications.views.device_manager_view.device_manager_view import (
ConfigChoiceDialog,
DeviceManagerView,
)
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.widgets.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
@pytest.fixture
def dm_view(qtbot):
"""Fixture for DeviceManagerView."""
widget = DeviceManagerView()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def config_choice_dialog(qtbot, dm_view):
"""Fixture for ConfigChoiceDialog."""
dialog = ConfigChoiceDialog(dm_view)
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
yield dialog
def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog):
"""Test the configuration choice dialog."""
assert config_choice_dialog is not None
assert config_choice_dialog.parent() == dm_view
# Test dialog components
with (
mock.patch.object(config_choice_dialog, "accept") as mock_accept,
mock.patch.object(config_choice_dialog, "reject") as mock_reject,
):
# Replace
qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton)
mock_accept.assert_called_once()
mock_reject.assert_not_called()
mock_accept.reset_mock()
assert config_choice_dialog.result() == config_choice_dialog.REPLACE
# Add
qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton)
mock_accept.assert_called_once()
mock_reject.assert_not_called()
mock_accept.reset_mock()
assert config_choice_dialog.result() == config_choice_dialog.ADD
# Cancel
qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton)
mock_accept.assert_not_called()
mock_reject.assert_called_once()
assert config_choice_dialog.result() == config_choice_dialog.CANCEL
class TestDeviceManagerViewInitialization:
"""Test class for DeviceManagerView initialization and basic components."""
def test_dock_manager_initialization(self, dm_view):
"""Test that the QtAds DockManager is properly initialized."""
assert dm_view.dock_manager is not None
assert dm_view.dock_manager.centralWidget() is not None
def test_central_widget_is_device_table_view(self, dm_view):
"""Test that the central widget is DeviceTableView."""
central_widget = dm_view.dock_manager.centralWidget().widget()
assert isinstance(central_widget, DeviceTableView)
assert central_widget is dm_view.device_table_view
def test_dock_widgets_exist(self, dm_view):
"""Test that all required dock widgets are created."""
dock_widgets = dm_view.dock_manager.dockWidgets()
# Check that we have the expected number of dock widgets
assert len(dock_widgets) >= 4
# Check for specific widget types
widget_types = [dock.widget().__class__ for dock in dock_widgets]
assert DMConfigView in widget_types
assert DMOphydTest in widget_types
assert DocstringView in widget_types
def test_toolbar_initialization(self, dm_view):
"""Test that the toolbar is properly initialized with expected bundles."""
assert dm_view.toolbar is not None
assert "IO" in dm_view.toolbar.bundles
assert "Table" in dm_view.toolbar.bundles
def test_toolbar_components_exist(self, dm_view):
"""Test that all expected toolbar components exist."""
expected_components = [
"load",
"save_to_disk",
"load_redis",
"update_config_redis",
"reset_composed",
"add_device",
"remove_device",
"rerun_validation",
]
for component in expected_components:
assert dm_view.toolbar.components.exists(component)
def test_signal_connections(self, dm_view):
"""Test that signals are properly connected between components."""
# Test that device_table_view signals are connected
assert dm_view.device_table_view.selected_devices is not None
assert dm_view.device_table_view.device_configs_changed is not None
# Test that ophyd_test_view signals are connected
assert dm_view.ophyd_test_view.device_validated is not None
class TestDeviceManagerViewIOBundle:
"""Test class for DeviceManagerView IO bundle actions."""
def test_io_bundle_exists(self, dm_view):
"""Test that IO bundle exists and contains expected actions."""
assert "IO" in dm_view.toolbar.bundles
io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"]
for action in io_actions:
assert dm_view.toolbar.components.exists(action)
def test_load_file_action_triggered(self, tmp_path, dm_view):
"""Test load file action trigger mechanism."""
with (
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
mock.patch(
"bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load"
) as mock_yaml_load,
mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog,
):
mock_yaml_data = {"device1": {"param1": "value1"}}
mock_yaml_load.return_value = mock_yaml_data
# Setup dialog mock
dm_view.toolbar.components._components["load"].action.action.triggered.emit()
mock_yaml_load.assert_called_once_with(tmp_path)
mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}])
def test_save_config_to_file(self, tmp_path, dm_view):
"""Test saving config to file."""
yaml_path = tmp_path / "test_save.yaml"
mock_config = [{"name": "device1", "param1": "value1"}]
with (
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path),
mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path),
mock.patch.object(
dm_view.device_table_view, "get_device_config", return_value=mock_config
),
):
dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit()
assert yaml_path.exists()
class TestDeviceManagerViewTableBundle:
"""Test class for DeviceManagerView Table bundle actions."""
def test_table_bundle_exists(self, dm_view):
"""Test that Table bundle exists and contains expected actions."""
assert "Table" in dm_view.toolbar.bundles
table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"]
for action in table_actions:
assert dm_view.toolbar.components.exists(action)
@mock.patch(
"bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question"
)
def test_reset_composed_view(self, mock_question, dm_view):
"""Test reset composed view when user confirms."""
with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear:
mock_question.return_value = QMessageBox.StandardButton.Yes
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
mock_clear.assert_called_once()
mock_clear.reset_mock()
mock_question.return_value = QMessageBox.StandardButton.No
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
mock_clear.assert_not_called()
def test_add_device_action_connected(self, dm_view):
"""Test add device action opens dialog correctly."""
with mock.patch.object(dm_view, "_add_device_action") as mock_add:
dm_view.toolbar.components._components["add_device"].action.action.triggered.emit()
mock_add.assert_called_once()
def test_remove_device_action(self, dm_view):
"""Test remove device action."""
with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove:
dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit()
mock_remove.assert_called_once()
def test_rerun_device_validation(self, dm_view):
"""Test rerun device validation action."""
cfgs = [{"name": "device1", "param1": "value1"}]
with (
mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change,
mock.patch.object(
dm_view.device_table_view.table, "selected_configs", return_value=cfgs
),
):
dm_view.toolbar.components._components[
"rerun_validation"
].action.action.triggered.emit()
mock_change.assert_called_once_with(cfgs, True, True)
+51
View File
@@ -1,9 +1,12 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from .client_mocks import mocked_client
@@ -79,3 +82,51 @@ def test_help_inspector_escape_key(qtbot, help_inspector):
assert not help_inspector._active
assert not help_inspector._button.isChecked()
assert QtWidgets.QApplication.overrideCursor() is None
def test_help_inspector_event_filter(help_inspector, abort_button):
"""Test the event filter of the HelpInspector."""
# Test nothing happens when not active
obj = mock.MagicMock(spec=QtWidgets.QWidget)
event = mock.MagicMock(spec=QtCore.QEvent)
assert help_inspector._active is False
with mock.patch.object(
QtWidgets.QWidget, "eventFilter", return_value=False
) as super_event_filter:
help_inspector.eventFilter(obj, event) # should do nothing and return False
super_event_filter.assert_called_once_with(obj, event)
super_event_filter.reset_mock()
help_inspector._active = True
with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle:
# Key press Escape
event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress)
event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape)
help_inspector.eventFilter(obj, event)
mock_toggle.assert_called_once_with(False)
mock_toggle.reset_mock()
# Click on itself
event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress)
event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton)
event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1))
with mock.patch.object(
help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button]
):
# Return for self call
help_inspector.eventFilter(obj, event)
mock_toggle.assert_called_once_with(False)
mock_toggle.reset_mock()
# Run Callback for abort_button
callback_data = []
def _my_callback(widget):
callback_data.append(widget)
help_inspector.register_callback(_my_callback)
help_inspector.eventFilter(obj, event)
mock_toggle.assert_not_called()
assert len(callback_data) == 1
assert callback_data[0] == abort_button
callback_data.clear()