mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-05 00:12:49 +01:00
379 lines
13 KiB
Python
379 lines
13 KiB
Python
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
from qtpy.QtCore import Qt
|
||
from qtpy.QtWidgets import (
|
||
QComboBox,
|
||
QLabel,
|
||
QLineEdit,
|
||
QPushButton,
|
||
QSpinBox,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from bec_widgets.utils.widget_io import WidgetHierarchy, WidgetIO, WidgetTreeNode
|
||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||
|
||
|
||
@pytest.fixture(scope="function")
|
||
def example_widget(qtbot):
|
||
# Create a widget with a few child widgets
|
||
main_widget = QWidget()
|
||
layout = QVBoxLayout(main_widget)
|
||
line_edit = QLineEdit(main_widget)
|
||
combo_box = QComboBox(main_widget)
|
||
table_widget = QTableWidget(2, 2, main_widget)
|
||
spin_box = QSpinBox(main_widget)
|
||
toggle = ToggleSwitch(main_widget)
|
||
|
||
layout.addWidget(line_edit)
|
||
layout.addWidget(combo_box)
|
||
layout.addWidget(table_widget)
|
||
layout.addWidget(spin_box)
|
||
layout.addWidget(toggle)
|
||
|
||
# Add text items to the combo box
|
||
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||
|
||
# Populate the table widget
|
||
table_widget.setItem(0, 0, QTableWidgetItem("Initial A"))
|
||
table_widget.setItem(0, 1, QTableWidgetItem("Initial B"))
|
||
table_widget.setItem(1, 0, QTableWidgetItem("Initial C"))
|
||
table_widget.setItem(1, 1, QTableWidgetItem("Initial D"))
|
||
|
||
qtbot.addWidget(main_widget)
|
||
qtbot.waitExposed(main_widget)
|
||
yield main_widget
|
||
|
||
|
||
def test_export_import_config(example_widget):
|
||
initial_config = {
|
||
"QWidget ()": {
|
||
"QLineEdit ()": {"value": "New Text"},
|
||
"QComboBox ()": {"value": 1},
|
||
"QTableWidget ()": {"value": [["a", "b"], ["c", "d"]]},
|
||
"QSpinBox ()": {"value": 10},
|
||
}
|
||
}
|
||
WidgetHierarchy.import_config_from_dict(example_widget, initial_config, set_values=True)
|
||
|
||
exported_config_full = WidgetHierarchy.export_config_to_dict(example_widget, grab_values=True)
|
||
exported_config_reduced = WidgetHierarchy.export_config_to_dict(
|
||
example_widget, grab_values=True, save_all=False
|
||
)
|
||
|
||
expected_full = {
|
||
"QWidget ()": {
|
||
"QComboBox ()": {"QStandardItemModel ()": {}, "value": 1},
|
||
"QLineEdit ()": {"QObject ()": {}, "value": "New Text"},
|
||
"QSpinBox ()": {
|
||
"QLineEdit (qt_spinbox_lineedit)": {"QObject ()": {}, "value": "10"},
|
||
"QValidator (qt_spinboxvalidator)": {},
|
||
"value": 10,
|
||
},
|
||
"QTableWidget ()": {
|
||
"QAbstractButton ()": {},
|
||
"QAbstractTableModel ()": {},
|
||
"QHeaderView ()": {
|
||
"QItemSelectionModel ()": {},
|
||
"QWidget (qt_scrollarea_hcontainer)": {
|
||
"QBoxLayout ()": {},
|
||
"QScrollBar ()": {},
|
||
},
|
||
"QWidget (qt_scrollarea_vcontainer)": {
|
||
"QBoxLayout ()": {},
|
||
"QScrollBar ()": {},
|
||
},
|
||
"QWidget (qt_scrollarea_viewport)": {},
|
||
},
|
||
"QItemSelectionModel ()": {},
|
||
"QStyledItemDelegate ()": {},
|
||
"QWidget (qt_scrollarea_hcontainer)": {"QBoxLayout ()": {}, "QScrollBar ()": {}},
|
||
"QWidget (qt_scrollarea_vcontainer)": {"QBoxLayout ()": {}, "QScrollBar ()": {}},
|
||
"QWidget (qt_scrollarea_viewport)": {},
|
||
"value": [["a", "b"], ["c", "d"]],
|
||
},
|
||
"QVBoxLayout ()": {},
|
||
"ToggleSwitch ()": {"value": True},
|
||
}
|
||
}
|
||
expected_reduced = {
|
||
"QWidget ()": {
|
||
"QComboBox ()": {"value": 1},
|
||
"QLineEdit ()": {"value": "New Text"},
|
||
"QSpinBox ()": {"QLineEdit (qt_spinbox_lineedit)": {"value": "10"}, "value": 10},
|
||
"QTableWidget ()": {"value": [["a", "b"], ["c", "d"]]},
|
||
"ToggleSwitch ()": {"value": True},
|
||
}
|
||
}
|
||
|
||
assert exported_config_full == expected_full
|
||
assert exported_config_reduced == expected_reduced
|
||
|
||
|
||
def test_widget_io_get_set_value(example_widget):
|
||
# Extract widgets
|
||
line_edit = example_widget.findChild(QLineEdit)
|
||
combo_box = example_widget.findChild(QComboBox)
|
||
table_widget = example_widget.findChild(QTableWidget)
|
||
spin_box = example_widget.findChild(QSpinBox)
|
||
toggle = example_widget.findChild(ToggleSwitch)
|
||
|
||
# Check initial values
|
||
assert WidgetIO.get_value(line_edit) == ""
|
||
assert WidgetIO.get_value(combo_box) == 0 # first index
|
||
assert WidgetIO.get_value(table_widget) == [
|
||
["Initial A", "Initial B"],
|
||
["Initial C", "Initial D"],
|
||
]
|
||
assert WidgetIO.get_value(spin_box) == 0
|
||
assert WidgetIO.get_value(toggle) == True
|
||
|
||
# Set new values
|
||
WidgetIO.set_value(line_edit, "Hello")
|
||
WidgetIO.set_value(combo_box, "Option 2")
|
||
WidgetIO.set_value(table_widget, [["X", "Y"], ["Z", "W"]])
|
||
WidgetIO.set_value(spin_box, 5)
|
||
WidgetIO.set_value(toggle, False)
|
||
|
||
# Check updated values
|
||
assert WidgetIO.get_value(line_edit) == "Hello"
|
||
assert WidgetIO.get_value(combo_box, as_string=True) == "Option 2"
|
||
assert WidgetIO.get_value(table_widget) == [["X", "Y"], ["Z", "W"]]
|
||
assert WidgetIO.get_value(spin_box) == 5
|
||
assert WidgetIO.get_value(toggle) == False
|
||
|
||
|
||
def test_widget_io_signal(qtbot, example_widget):
|
||
# Extract widgets
|
||
line_edit = example_widget.findChild(QLineEdit)
|
||
combo_box = example_widget.findChild(QComboBox)
|
||
spin_box = example_widget.findChild(QSpinBox)
|
||
table_widget = example_widget.findChild(QTableWidget)
|
||
toggle = example_widget.findChild(ToggleSwitch)
|
||
|
||
# We'll store changes in a list to verify the slot is called
|
||
changes = []
|
||
|
||
def universal_slot(w, val):
|
||
changes.append((w, val))
|
||
|
||
# Connect signals
|
||
WidgetIO.connect_widget_change_signal(line_edit, universal_slot)
|
||
WidgetIO.connect_widget_change_signal(combo_box, universal_slot)
|
||
WidgetIO.connect_widget_change_signal(spin_box, universal_slot)
|
||
WidgetIO.connect_widget_change_signal(table_widget, universal_slot)
|
||
WidgetIO.connect_widget_change_signal(toggle, universal_slot)
|
||
|
||
# Trigger changes
|
||
line_edit.setText("NewText")
|
||
qtbot.waitUntil(lambda: len(changes) > 0)
|
||
assert changes[-1][1] == "NewText"
|
||
|
||
combo_box.setCurrentIndex(2)
|
||
qtbot.waitUntil(lambda: len(changes) > 1)
|
||
# combo_box change should give the current index or value
|
||
# We set "Option 3" is index 2
|
||
assert changes[-1][1] == 2 or changes[-1][1] == "Option 3"
|
||
|
||
spin_box.setValue(42)
|
||
qtbot.waitUntil(lambda: len(changes) > 2)
|
||
assert changes[-1][1] == 42
|
||
|
||
# For the table widget, changing a cell triggers cellChanged
|
||
table_widget.setItem(0, 0, QTableWidgetItem("ChangedCell"))
|
||
qtbot.waitUntil(lambda: len(changes) > 3)
|
||
# The entire table value should be retrieved
|
||
assert changes[-1][1][0][0] == "ChangedCell"
|
||
|
||
# Test the toggle switch
|
||
toggle.checked = False
|
||
qtbot.waitUntil(lambda: len(changes) > 4)
|
||
assert changes[-1][1] == False
|
||
|
||
|
||
def test_find_widgets(example_widget):
|
||
# Test find_widgets by class type
|
||
line_edits = WidgetIO.find_widgets(QLineEdit)
|
||
assert len(line_edits) == 2 # one LineEdit and one in the SpinBox
|
||
assert isinstance(line_edits[0], QLineEdit)
|
||
|
||
# Test find_widgets by class-name string
|
||
combo_boxes = WidgetIO.find_widgets("QComboBox")
|
||
assert len(combo_boxes) == 1
|
||
assert isinstance(combo_boxes[0], QComboBox)
|
||
|
||
# Test non-recursive search returns the same widgets
|
||
combo_boxes_flat = WidgetIO.find_widgets(QComboBox, recursive=False)
|
||
assert combo_boxes_flat == combo_boxes
|
||
|
||
# Test search for non-existent widget returns empty list
|
||
non_exist = WidgetIO.find_widgets("NonExistentWidget")
|
||
assert non_exist == []
|
||
|
||
|
||
# ── WidgetHierarchy._build_prefix ─────────────────────────────────────────────
|
||
|
||
|
||
def test_build_prefix_empty():
|
||
assert WidgetHierarchy._build_prefix([]) == ""
|
||
|
||
|
||
def test_build_prefix_single_last():
|
||
assert WidgetHierarchy._build_prefix([True]) == "└─ "
|
||
|
||
|
||
def test_build_prefix_single_not_last():
|
||
assert WidgetHierarchy._build_prefix([False]) == "├─ "
|
||
|
||
|
||
def test_build_prefix_nested_last_last():
|
||
# parent is last (True), child is last (True): " └─ "
|
||
assert WidgetHierarchy._build_prefix([True, True]) == " └─ "
|
||
|
||
|
||
def test_build_prefix_nested_not_last_last():
|
||
# parent is not last (False), child is last (True): "│ └─ "
|
||
assert WidgetHierarchy._build_prefix([False, True]) == "│ └─ "
|
||
|
||
|
||
def test_build_prefix_nested_not_last_not_last():
|
||
# parent is not last (False), child is not last (False): "│ ├─ "
|
||
assert WidgetHierarchy._build_prefix([False, False]) == "│ ├─ "
|
||
|
||
|
||
def test_build_prefix_deep_tree():
|
||
# Three levels: not-last → not-last → last
|
||
assert WidgetHierarchy._build_prefix([False, False, True]) == "│ │ └─ "
|
||
|
||
|
||
# ── WidgetHierarchy._filtered_children ────────────────────────────────────────
|
||
|
||
|
||
def test_filtered_children_plain_widget(qtbot):
|
||
parent = QWidget()
|
||
qtbot.addWidget(parent)
|
||
child1 = QLabel(parent)
|
||
child2 = QPushButton(parent)
|
||
result = WidgetHierarchy._filtered_children(parent, exclude_internal_widgets=True)
|
||
assert child1 in result
|
||
assert child2 in result
|
||
|
||
|
||
def test_filtered_children_combobox_excludes_internal(qtbot):
|
||
combo = QComboBox()
|
||
qtbot.addWidget(combo)
|
||
combo.addItems(["a", "b"])
|
||
result_excluded = WidgetHierarchy._filtered_children(combo, exclude_internal_widgets=True)
|
||
result_included = WidgetHierarchy._filtered_children(combo, exclude_internal_widgets=False)
|
||
internal_names = {c.__class__.__name__ for c in result_included}
|
||
excluded_names = {c.__class__.__name__ for c in result_excluded}
|
||
# At least one internal widget type is present when not excluded
|
||
assert internal_names & {"QFrame", "QListView"}
|
||
# The same internal types should be absent when excluded
|
||
assert not (excluded_names & {"QFrame", "QListView"})
|
||
|
||
|
||
def test_filtered_children_skips_invalid_child(qtbot):
|
||
parent = QWidget()
|
||
qtbot.addWidget(parent)
|
||
child = QLabel(parent)
|
||
with mock.patch("shiboken6.isValid", side_effect=lambda w: w is not child):
|
||
result = WidgetHierarchy._filtered_children(parent, exclude_internal_widgets=False)
|
||
assert child not in result
|
||
|
||
|
||
# ── WidgetHierarchy.iter_widget_tree ──────────────────────────────────────────
|
||
|
||
|
||
def test_iter_widget_tree_basic(qtbot):
|
||
root = QWidget()
|
||
qtbot.addWidget(root)
|
||
child = QLabel(root)
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(root))
|
||
widgets = [n.widget for n in nodes]
|
||
assert root in widgets
|
||
assert child in widgets
|
||
|
||
|
||
def test_iter_widget_tree_root_node_attrs(qtbot):
|
||
root = QWidget()
|
||
qtbot.addWidget(root)
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(root))
|
||
root_node = nodes[0]
|
||
assert root_node.widget is root
|
||
assert root_node.parent is None
|
||
assert root_node.depth == 0
|
||
assert root_node.prefix == ""
|
||
|
||
|
||
def test_iter_widget_tree_child_node_attrs(qtbot):
|
||
root = QWidget()
|
||
qtbot.addWidget(root)
|
||
child = QLabel(root)
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(root))
|
||
child_node = next(n for n in nodes if n.widget is child)
|
||
assert child_node.parent is root
|
||
assert child_node.depth == 1
|
||
assert child_node.prefix in ("├─ ", "└─ ")
|
||
|
||
|
||
def test_iter_widget_tree_none_widget():
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(None))
|
||
assert nodes == []
|
||
|
||
|
||
def test_iter_widget_tree_invalid_widget(qtbot):
|
||
root = QWidget()
|
||
qtbot.addWidget(root)
|
||
with mock.patch("shiboken6.isValid", return_value=False):
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(root))
|
||
assert nodes == []
|
||
|
||
|
||
def test_iter_widget_tree_prevents_revisiting(qtbot):
|
||
"""Visited-set prevents infinite loops when the same widget is returned
|
||
more than once by _filtered_children (simulates a circular-reference
|
||
scenario that cannot arise naturally in Qt but is handled defensively)."""
|
||
root = QWidget()
|
||
qtbot.addWidget(root)
|
||
child = QLabel(root)
|
||
|
||
call_count = 0
|
||
|
||
original = WidgetHierarchy._filtered_children
|
||
|
||
def patched(widget, exclude):
|
||
nonlocal call_count
|
||
result = original(widget, exclude)
|
||
# After the first call (for root), inject root itself as a child of child
|
||
if widget is child:
|
||
call_count += 1
|
||
result = [root] + result # root already visited – should be skipped
|
||
return result
|
||
|
||
with mock.patch.object(WidgetHierarchy, "_filtered_children", staticmethod(patched)):
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(root))
|
||
|
||
widget_ids = [id(n.widget) for n in nodes]
|
||
# root must appear exactly once despite being injected as a child of child
|
||
assert widget_ids.count(id(root)) == 1
|
||
assert call_count >= 1
|
||
|
||
|
||
def test_iter_widget_tree_depth_and_prefix_for_multiple_children(qtbot):
|
||
root = QWidget()
|
||
qtbot.addWidget(root)
|
||
first = QLabel(root)
|
||
second = QPushButton(root)
|
||
nodes = list(WidgetHierarchy.iter_widget_tree(root))
|
||
child_nodes = [n for n in nodes if n.depth == 1]
|
||
prefixes = {n.prefix for n in child_nodes}
|
||
# Non-last child gets "├─ " and last child gets "└─ "
|
||
assert "├─ " in prefixes
|
||
assert "└─ " in prefixes
|