mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-12-27 17:41:17 +01:00
870 lines
32 KiB
Python
870 lines
32 KiB
Python
"""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"]
|