mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-08 01:37:52 +01:00
test(device-manager-components): test for device manager components
This commit is contained in:
@@ -150,6 +150,9 @@ class WrappingTextDelegate(CustomDisplayDelegate):
|
||||
) -> QtGui.QTextLayout:
|
||||
"""Compute and return the text layout for given text and option."""
|
||||
layout = self._get_layout(text, option.font)
|
||||
text_option = QtGui.QTextOption()
|
||||
text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere)
|
||||
layout.setTextOption(text_option)
|
||||
layout.beginLayout()
|
||||
height = 0
|
||||
max_lines = 100 # safety cap, should never be more than 100 lines..
|
||||
@@ -211,7 +214,8 @@ class WrappingTextDelegate(CustomDisplayDelegate):
|
||||
):
|
||||
"""Only update rows if a wrapped column was resized."""
|
||||
self._cache.clear()
|
||||
self._update_row_heights()
|
||||
# Make sure layout is computed first
|
||||
QtCore.QTimer.singleShot(0, self._update_row_heights)
|
||||
|
||||
def _update_row_heights(self):
|
||||
"""Efficiently adjust row heights based on wrapped columns."""
|
||||
@@ -662,7 +666,24 @@ class BECTableView(QtWidgets.QTableView):
|
||||
"""
|
||||
configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())]
|
||||
names = [cfg.get("name", "<unknown>") for cfg in configs]
|
||||
if not names:
|
||||
logger.warning("No device names found for selected rows.")
|
||||
return False
|
||||
if self._remove_rows_msg_dialog(names):
|
||||
model.remove_device_configs(configs)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _remove_rows_msg_dialog(self, names: list[str]) -> bool:
|
||||
"""
|
||||
Prompt the user to confirm removal of rows and remove them from the model if accepted.
|
||||
|
||||
Args:
|
||||
names (list[str]): List of device names to be removed.
|
||||
|
||||
Returns:
|
||||
bool: True if the user confirmed removal, False otherwise.
|
||||
"""
|
||||
msg = QMessageBox(self)
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setWindowTitle("Confirm device removal")
|
||||
@@ -676,7 +697,6 @@ class BECTableView(QtWidgets.QTableView):
|
||||
|
||||
res = msg.exec_()
|
||||
if res == QMessageBox.StandardButton.Ok:
|
||||
model.remove_device_configs(configs)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -946,6 +966,7 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
# Connect model signals to autosize request
|
||||
self._model.rowsInserted.connect(self._request_autosize_columns)
|
||||
self._model.rowsRemoved.connect(self._request_autosize_columns)
|
||||
self._model.modelReset.connect(self._request_autosize_columns)
|
||||
self._model.dataChanged.connect(self._request_autosize_columns)
|
||||
|
||||
@@ -1088,8 +1109,21 @@ if __name__ == "__main__":
|
||||
button.clicked.connect(_button_clicked)
|
||||
# pylint: disable=protected-access
|
||||
config = window.client.device_manager._get_redis_device_config()
|
||||
config.insert(
|
||||
0,
|
||||
{
|
||||
"name": "TestDevice",
|
||||
"deviceClass": "bec.devices.MockDevice",
|
||||
"description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.",
|
||||
"deviceTags": ["test", "mock", "longtagnameexample"],
|
||||
"enabled": True,
|
||||
"readOnly": False,
|
||||
"softwareTrigger": True,
|
||||
},
|
||||
)
|
||||
# names = [cfg.pop("name") for cfg in config]
|
||||
# config_dict = {name: cfg for name, cfg in zip(names, config)}
|
||||
window.set_device_config(config)
|
||||
window.resize(1920, 1200)
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
869
tests/unit_tests/test_device_manager_components.py
Normal file
869
tests/unit_tests/test_device_manager_components.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user