1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-05 00:12:49 +01:00
Files
bec_widgets/tests/unit_tests/test_widget_io.py

379 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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