fix: replace custom tree with builtin filebrowser

This commit is contained in:
2026-07-14 16:59:40 +02:00
committed by wakonig_k
parent e8902d1ea2
commit cfa38e549f
15 changed files with 1072 additions and 1492 deletions
+13 -1
View File
@@ -104,7 +104,7 @@ class TestDeveloperViewInitialization:
# Check for expected toolbar actions
toolbar_components = developer_view.toolbar.components
expected_actions = ["save", "save_as", "run", "stop", "vim"]
expected_actions = ["new_script", "new_macro", "save", "save_as", "run", "stop", "vim"]
for action_name in expected_actions:
assert toolbar_components.exists(action_name)
@@ -208,6 +208,18 @@ class TestFileOperations:
developer_view.on_save_as()
mock_save.assert_called_once_with(force_save_as=True)
def test_new_script_functionality(self, developer_view):
"""Test the new script toolbar action handler."""
with mock.patch.object(developer_view.explorer, "_add_local_script") as mock_new_script:
developer_view.on_new_script()
mock_new_script.assert_called_once()
def test_new_macro_functionality(self, developer_view):
"""Test the new macro toolbar action handler."""
with mock.patch.object(developer_view.explorer, "_add_local_macro") as mock_new_macro:
developer_view.on_new_macro()
mock_new_macro.assert_called_once()
class TestMonacoEditorIntegration:
"""Test Monaco editor specific functionality."""
@@ -0,0 +1,270 @@
from pathlib import Path
import pytest
from qtpy.QtCore import QModelIndex, QPoint, Qt
from qtpy.QtWidgets import QAbstractItemView, QInputDialog, QMenu
from bec_widgets.widgets.containers.explorer.file_browser_tree_widget import FileBrowserTreeWidget
@pytest.fixture
def file_browser_tree(qtbot, tmpdir):
"""Create a FileBrowserTreeWidget rooted at tmpdir."""
(Path(tmpdir) / "test_file.py").touch()
(Path(tmpdir) / "__init__.py").touch()
(Path(tmpdir) / "test_dir").mkdir()
(Path(tmpdir) / "test_dir" / "nested_file.py").touch()
(Path(tmpdir) / "__pycache__").mkdir()
(Path(tmpdir) / "__pycache__" / "ignored.py").touch()
widget = FileBrowserTreeWidget(directory=str(tmpdir))
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_file_browser_tree_set_directory(file_browser_tree, tmpdir):
"""Test setting the directory."""
assert file_browser_tree.directory == str(tmpdir)
def test_file_browser_tree_hides_filtered_entries(file_browser_tree, qtbot):
"""Test that filtered cache/init entries are hidden from the browser."""
def visible_names():
root_index = file_browser_tree.tree.rootIndex()
return [
file_browser_tree.proxy_model.data(
file_browser_tree.proxy_model.index(i, 0, root_index)
)
for i in range(file_browser_tree.proxy_model.rowCount(root_index))
]
qtbot.waitUntil(lambda: "test_file.py" in visible_names(), timeout=5000)
assert "__pycache__" not in visible_names()
assert "__init__.py" not in visible_names()
def test_file_browser_tree_native_browser_configuration(file_browser_tree):
"""Test that the tree is configured like a plain native file browser."""
assert file_browser_tree.model.isReadOnly() is False
assert file_browser_tree.tree.alternatingRowColors() is False
assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.InternalMove
assert file_browser_tree.tree.defaultDropAction() == Qt.DropAction.MoveAction
assert (
file_browser_tree.tree.selectionBehavior() == QAbstractItemView.SelectionBehavior.SelectRows
)
def test_file_browser_tree_set_readonly(file_browser_tree):
"""Test toggling read-only mode on the file browser."""
file_browser_tree.set_readonly(True)
assert file_browser_tree.model.isReadOnly() is True
assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.NoDragDrop
file_browser_tree.set_readonly(False)
assert file_browser_tree.model.isReadOnly() is False
assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.InternalMove
@pytest.mark.timeout(10)
def test_file_browser_tree_on_item_clicked(file_browser_tree, qtbot):
"""Test that clicks and double-clicks emit the expected file signals."""
file_selected_signals = []
file_open_requested_signals = []
def on_file_selected(file_path):
file_selected_signals.append(file_path)
def on_file_open_requested(file_path):
file_open_requested_signals.append(file_path)
file_browser_tree.file_selected.connect(on_file_selected)
file_browser_tree.file_open_requested.connect(on_file_open_requested)
def has_py_file():
nonlocal py_file_index
root_index = file_browser_tree.tree.rootIndex()
for i in range(file_browser_tree.proxy_model.rowCount(root_index)):
index = file_browser_tree.proxy_model.index(i, 0, root_index)
if file_browser_tree.proxy_model.data(index) == "test_file.py":
py_file_index = index
return True
return False
py_file_index = None
qtbot.waitUntil(has_py_file)
file_browser_tree._on_item_clicked(py_file_index)
qtbot.wait(100)
py_file_index = None
qtbot.waitUntil(has_py_file)
file_browser_tree._on_item_double_clicked(py_file_index)
qtbot.wait(100)
assert len(file_selected_signals) == 1
assert Path(file_selected_signals[0]).name == "test_file.py"
assert len(file_open_requested_signals) == 1
assert Path(file_open_requested_signals[0]).name == "test_file.py"
def test_file_browser_tree_delete_request_emits_selected_file(file_browser_tree, qtbot):
"""Test that delete requests emit the currently selected file path."""
delete_requests = []
def on_delete_requested(file_path):
delete_requests.append(file_path)
file_browser_tree.file_delete_requested.connect(on_delete_requested)
def get_test_file_index():
root_index = file_browser_tree.tree.rootIndex()
for i in range(file_browser_tree.proxy_model.rowCount(root_index)):
index = file_browser_tree.proxy_model.index(i, 0, root_index)
if file_browser_tree.proxy_model.data(index) == "test_file.py":
return index
return None
qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000)
file_index = get_test_file_index()
assert file_index is not None
file_browser_tree.tree.setCurrentIndex(file_index)
file_browser_tree._on_file_delete_requested()
qtbot.wait(100)
assert len(delete_requests) == 1
assert Path(delete_requests[0]).name == "test_file.py"
def test_file_browser_tree_context_menu_rename_action(file_browser_tree, qtbot, monkeypatch):
"""Test that the context menu exposes a rename action for editable items."""
def get_test_file_index():
root_index = file_browser_tree.tree.rootIndex()
for i in range(file_browser_tree.proxy_model.rowCount(root_index)):
index = file_browser_tree.proxy_model.index(i, 0, root_index)
if file_browser_tree.proxy_model.data(index) == "test_file.py":
return index
return None
qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000)
file_index = get_test_file_index()
assert file_index is not None
called = {}
def fake_edit(index):
called["name"] = file_browser_tree.proxy_model.data(index)
return True
monkeypatch.setattr(file_browser_tree.tree, "edit", fake_edit)
menu = file_browser_tree._build_context_menu(file_index)
assert menu is not None
assert [action.text() for action in menu.actions()] == [
"New Folder",
"Open",
"Rename",
"Delete File",
]
menu.actions()[2].trigger()
assert called["name"] == "test_file.py"
def test_file_browser_tree_context_menu_open_action(file_browser_tree, qtbot):
"""Test that the context menu exposes an open action for files."""
opened = []
file_browser_tree.file_open_requested.connect(opened.append)
def get_test_file_index():
root_index = file_browser_tree.tree.rootIndex()
for i in range(file_browser_tree.proxy_model.rowCount(root_index)):
index = file_browser_tree.proxy_model.index(i, 0, root_index)
if file_browser_tree.proxy_model.data(index) == "test_file.py":
return index
return None
qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000)
file_index = get_test_file_index()
assert file_index is not None
file_browser_tree.tree.setCurrentIndex(file_index)
menu = file_browser_tree._build_context_menu(file_index)
assert menu is not None
menu.actions()[1].trigger()
assert len(opened) == 1
assert Path(opened[0]).name == "test_file.py"
def test_file_browser_tree_context_menu_root_allows_new_folder(file_browser_tree):
"""Test that right-clicking the root area offers folder creation."""
menu = file_browser_tree._build_context_menu(QModelIndex())
assert menu is not None
assert [action.text() for action in menu.actions()] == ["New Folder"]
def test_file_browser_tree_context_menu_hidden_when_read_only(file_browser_tree, monkeypatch):
"""Test that no context menu is shown in read-only mode."""
file_browser_tree.set_readonly(True)
called = {"shown": False}
def fake_exec(*_args, **_kwargs):
called["shown"] = True
return None
monkeypatch.setattr(QMenu, "exec", fake_exec)
file_browser_tree._show_context_menu(QPoint(0, 0))
assert called["shown"] is False
def test_file_browser_tree_emits_file_renamed_signal(file_browser_tree):
"""Test that model rename notifications are emitted as full paths."""
renamed = []
file_browser_tree.file_renamed.connect(lambda old, new: renamed.append((old, new)))
file_browser_tree._on_model_file_renamed(str(file_browser_tree.directory), "old.py", "new.py")
assert renamed == [
(
str(Path(file_browser_tree.directory) / "old.py"),
str(Path(file_browser_tree.directory) / "new.py"),
)
]
def test_file_browser_tree_create_subdirectory_at_root(file_browser_tree, monkeypatch):
"""Test creating a subdirectory at the root level."""
monkeypatch.setattr(QInputDialog, "getText", lambda *_args, **_kwargs: ("new_root_dir", True))
file_browser_tree._create_subdirectory(str(file_browser_tree.directory))
assert (Path(file_browser_tree.directory) / "new_root_dir").is_dir()
def test_file_browser_tree_create_subdirectory_in_selected_directory(
file_browser_tree, qtbot, monkeypatch
):
"""Test creating a subdirectory inside the selected directory level."""
def get_test_dir_index():
root_index = file_browser_tree.tree.rootIndex()
for i in range(file_browser_tree.proxy_model.rowCount(root_index)):
index = file_browser_tree.proxy_model.index(i, 0, root_index)
if file_browser_tree.proxy_model.data(index) == "test_dir":
return index
return None
qtbot.waitUntil(lambda: get_test_dir_index() is not None, timeout=5000)
dir_index = get_test_dir_index()
assert dir_index is not None
assert file_browser_tree._get_target_directory(dir_index).endswith("test_dir")
monkeypatch.setattr(QInputDialog, "getText", lambda *_args, **_kwargs: ("child_dir", True))
file_browser_tree._create_subdirectory(file_browser_tree._get_target_directory(dir_index))
assert (Path(file_browser_tree.directory) / "test_dir" / "child_dir").is_dir()
+127 -12
View File
@@ -258,12 +258,131 @@ def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir):
assert os.path.exists(expected_file)
# Check that the file contains the expected function
with open(expected_file, "r") as f:
with open(expected_file, "r", encoding="utf-8") as f:
content = f.read()
assert "def test_macro_function():" in content
assert "test_macro_function macro" in content
def test_ide_explorer_delete_local_script(ide_explorer, tmpdir):
"""Test deleting a local script file."""
local_script_section = ide_explorer.main_explorer.get_section(
"SCRIPTS"
).content_widget.get_section("Local")
local_script_section.content_widget.set_directory(str(tmpdir))
file_path = os.path.join(tmpdir, "delete_me.py")
Path(file_path).write_text("print('delete me')", encoding="utf-8")
with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes):
ide_explorer._delete_local_script(file_path)
assert not os.path.exists(file_path)
def test_ide_explorer_delete_local_script_directory(ide_explorer, tmpdir):
"""Test deleting a local script directory."""
local_script_section = ide_explorer.main_explorer.get_section(
"SCRIPTS"
).content_widget.get_section("Local")
local_script_section.content_widget.set_directory(str(tmpdir))
script_dir = Path(tmpdir) / "subdir"
script_dir.mkdir()
nested_file = script_dir / "nested.py"
nested_file.write_text("print('nested')", encoding="utf-8")
with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes):
ide_explorer._delete_local_script(str(script_dir))
assert not script_dir.exists()
def test_ide_explorer_delete_local_macro_broadcasts_removals(ide_explorer, tmpdir):
"""Test deleting a local macro unloads loaded macros and removes the file."""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
file_path = os.path.join(tmpdir, "delete_macro.py")
Path(file_path).write_text("def delete_macro():\n pass\n", encoding="utf-8")
ide_explorer.client.macros = mock.MagicMock()
ide_explorer.client.macros._update_handler = mock.MagicMock()
ide_explorer.client.macros._update_handler.get_existing_macros.return_value = {
"delete_macro": {"fname": file_path}
}
with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes):
ide_explorer._delete_local_macro(file_path)
ide_explorer.client.macros._update_handler.broadcast.assert_called_once_with(
action="remove", name="delete_macro"
)
assert not os.path.exists(file_path)
def test_ide_explorer_delete_local_macro_directory_broadcasts_removals(ide_explorer, tmpdir):
"""Test deleting a local macro directory unloads macros from contained files."""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
macro_dir = Path(tmpdir) / "subdir"
macro_dir.mkdir()
file_path = macro_dir / "delete_macro.py"
file_path.write_text("def delete_macro():\n pass\n", encoding="utf-8")
ide_explorer.client.macros = mock.MagicMock()
ide_explorer.client.macros._update_handler = mock.MagicMock()
ide_explorer.client.macros._update_handler.get_existing_macros.return_value = {
"delete_macro": {"fname": str(file_path)}
}
with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes):
ide_explorer._delete_local_macro(str(macro_dir))
ide_explorer.client.macros._update_handler.broadcast.assert_called_once_with(
action="remove", name="delete_macro"
)
assert not macro_dir.exists()
def test_ide_explorer_rename_local_macro_path_reloads_macros(ide_explorer, tmpdir):
"""Test local macro renames refresh open-editor and macro state."""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
old_path = os.path.join(tmpdir, "old_name.py")
new_path = os.path.join(tmpdir, "new_name.py")
ide_explorer.client.macros = mock.MagicMock()
ide_explorer.client.macros._update_handler = mock.MagicMock()
with (
mock.patch.object(ide_explorer, "_rename_open_editor_path") as mock_rename_open_editor,
mock.patch.object(ide_explorer, "_broadcast_removed_macros") as mock_broadcast_removed,
):
ide_explorer._rename_local_macro_path(old_path, new_path)
mock_broadcast_removed.assert_called_once_with(old_path)
mock_rename_open_editor.assert_called_once_with(old_path, new_path)
ide_explorer.client.macros.load_all_user_macros.assert_called_once()
def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro with invalid function name"""
ide_explorer.clear()
@@ -410,14 +529,12 @@ def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir):
macro_file = Path(tmpdir) / "test_macro.py"
macro_file.write_text("def test_function(): pass")
# Mock the refresh_file_item method
with mock.patch.object(
local_macros_section.content_widget, "refresh_file_item"
) as mock_refresh:
# Mock the refresh method
with mock.patch.object(local_macros_section.content_widget, "refresh") as mock_refresh:
ide_explorer.refresh_macro_file(str(macro_file))
# Should call refresh_file_item with the file path
mock_refresh.assert_called_once_with(str(macro_file))
# Should refresh the file browser
mock_refresh.assert_called_once_with()
def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir):
@@ -434,13 +551,11 @@ def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir):
# Try to refresh a file that's not in any macro directory
unrelated_file = "/some/other/path/unrelated.py"
# Mock the refresh_file_item method
with mock.patch.object(
local_macros_section.content_widget, "refresh_file_item"
) as mock_refresh:
# Mock the refresh method
with mock.patch.object(local_macros_section.content_widget, "refresh") as mock_refresh:
ide_explorer.refresh_macro_file(unrelated_file)
# Should not call refresh_file_item
# Should not refresh for unrelated files
mock_refresh.assert_not_called()
-534
View File
@@ -1,534 +0,0 @@
"""
Unit tests for the MacroTreeWidget.
"""
from pathlib import Path
import pytest
from qtpy.QtCore import QEvent, QModelIndex, Qt
from qtpy.QtGui import QMouseEvent
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
@pytest.fixture
def temp_macro_files(tmpdir):
"""Create temporary macro files for testing."""
macro_dir = Path(tmpdir) / "macros"
macro_dir.mkdir()
# Create a simple macro file with functions
macro_file1 = macro_dir / "test_macros.py"
macro_file1.write_text('''
def test_macro_function():
"""A test macro function."""
return "test"
def another_function(param1, param2):
"""Another function with parameters."""
return param1 + param2
class TestClass:
"""This class should be ignored."""
def method(self):
pass
''')
# Create another macro file
macro_file2 = macro_dir / "utils_macros.py"
macro_file2.write_text('''
def utility_function():
"""A utility function."""
pass
def deprecated_function():
"""Old function."""
return None
''')
# Create a file with no functions (should be ignored)
empty_file = macro_dir / "empty.py"
empty_file.write_text("""
# Just a comment
x = 1
y = 2
""")
# Create a file starting with underscore (should be ignored)
private_file = macro_dir / "_private.py"
private_file.write_text("""
def private_function():
return "private"
""")
# Create a file with syntax errors
error_file = macro_dir / "error_file.py"
error_file.write_text("""
def broken_function(
# Missing closing parenthesis and colon
pass
""")
return macro_dir
@pytest.fixture
def macro_tree(qtbot, temp_macro_files):
"""Create a MacroTreeWidget with test macro files."""
widget = MacroTreeWidget()
widget.set_directory(str(temp_macro_files))
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
class TestMacroTreeWidgetInitialization:
"""Test macro tree widget initialization and basic functionality."""
def test_initialization(self, qtbot):
"""Test that the macro tree widget initializes correctly."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Check basic properties
assert widget.tree is not None
assert widget.model is not None
assert widget.delegate is not None
assert widget.directory is None
# Check that tree is configured properly
assert widget.tree.isHeaderHidden()
assert widget.tree.rootIsDecorated()
assert not widget.tree.editTriggers()
def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files):
"""Test setting a valid directory path."""
assert macro_tree.directory == str(temp_macro_files)
# Check that files were loaded
assert macro_tree.model.rowCount() > 0
# Should have 2 files (test_macros.py and utils_macros.py)
# empty.py and _private.py should be filtered out
expected_files = ["test_macros", "utils_macros"]
actual_files = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
actual_files.append(item.text())
# Sort for consistent comparison
actual_files.sort()
expected_files.sort()
for expected in expected_files:
assert expected in actual_files
def test_set_directory_with_invalid_path(self, qtbot):
"""Test setting an invalid directory path."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
widget.set_directory("/nonexistent/path")
# Should handle gracefully
assert widget.directory == "/nonexistent/path"
assert widget.model.rowCount() == 0
def test_set_directory_with_none(self, qtbot):
"""Test setting directory to None."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
widget.set_directory(None)
# Should handle gracefully
assert widget.directory is None
assert widget.model.rowCount() == 0
class TestMacroFunctionParsing:
"""Test macro function parsing and AST functionality."""
def test_extract_functions_from_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a Python file."""
test_file = temp_macro_files / "test_macros.py"
functions = macro_tree._extract_functions_from_file(test_file)
# Should extract 2 functions, not the class method
assert len(functions) == 2
assert "test_macro_function" in functions
assert "another_function" in functions
assert "method" not in functions # Class methods should be excluded
# Check function details
test_func = functions["test_macro_function"]
assert test_func["line_number"] == 2 # First function starts at line 2
assert "A test macro function" in test_func["docstring"]
def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a file with no functions."""
empty_file = temp_macro_files / "empty.py"
functions = macro_tree._extract_functions_from_file(empty_file)
assert len(functions) == 0
def test_extract_functions_from_invalid_file(self, macro_tree):
"""Test extracting functions from a non-existent file."""
nonexistent_file = Path("/nonexistent/file.py")
functions = macro_tree._extract_functions_from_file(nonexistent_file)
assert len(functions) == 0
def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a file with syntax errors."""
error_file = temp_macro_files / "error_file.py"
functions = macro_tree._extract_functions_from_file(error_file)
# Should return empty dict on syntax error
assert len(functions) == 0
def test_create_file_item(self, macro_tree, temp_macro_files):
"""Test creating a file item from a Python file."""
test_file = temp_macro_files / "test_macros.py"
file_item = macro_tree._create_file_item(test_file)
assert file_item is not None
assert file_item.text() == "test_macros"
assert file_item.rowCount() == 2 # Should have 2 function children
# Check file data
file_data = file_item.data(Qt.ItemDataRole.UserRole)
assert file_data["type"] == "file"
assert file_data["file_path"] == str(test_file)
# Check function children
func_names = []
for row in range(file_item.rowCount()):
child = file_item.child(row)
func_names.append(child.text())
# Check function data
func_data = child.data(Qt.ItemDataRole.UserRole)
assert func_data["type"] == "function"
assert func_data["file_path"] == str(test_file)
assert "function_name" in func_data
assert "line_number" in func_data
assert "test_macro_function" in func_names
assert "another_function" in func_names
def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files):
"""Test that files starting with underscore are ignored."""
private_file = temp_macro_files / "_private.py"
file_item = macro_tree._create_file_item(private_file)
assert file_item is None
def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files):
"""Test that files with no functions return None."""
empty_file = temp_macro_files / "empty.py"
file_item = macro_tree._create_file_item(empty_file)
assert file_item is None
class TestMacroTreeInteractions:
"""Test macro tree widget interactions and signals."""
def test_item_click_on_function(self, macro_tree, qtbot):
"""Test clicking on a function item."""
# Set up signal spy
macro_selected_signals = []
def on_macro_selected(function_name, file_path):
macro_selected_signals.append((function_name, file_path))
macro_tree.macro_selected.connect(on_macro_selected)
# Find a function item
file_item = macro_tree.model.item(0) # First file
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0) # First function
func_index = func_item.index()
# Simulate click
macro_tree._on_item_clicked(func_index)
# Check signal was emitted
assert len(macro_selected_signals) == 1
function_name, file_path = macro_selected_signals[0]
assert function_name is not None
assert file_path is not None
assert file_path.endswith(".py")
def test_item_click_on_file(self, macro_tree, qtbot):
"""Test clicking on a file item (should not emit signal)."""
# Set up signal spy
macro_selected_signals = []
def on_macro_selected(function_name, file_path):
macro_selected_signals.append((function_name, file_path))
macro_tree.macro_selected.connect(on_macro_selected)
# Find a file item
file_item = macro_tree.model.item(0)
if file_item:
file_index = file_item.index()
# Simulate click
macro_tree._on_item_clicked(file_index)
# Should not emit signal for file items
assert len(macro_selected_signals) == 0
def test_item_double_click_on_function(self, macro_tree, qtbot):
"""Test double-clicking on a function item."""
# Set up signal spy
open_requested_signals = []
def on_macro_open_requested(function_name, file_path):
open_requested_signals.append((function_name, file_path))
macro_tree.macro_open_requested.connect(on_macro_open_requested)
# Find a function item
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Simulate double-click
macro_tree._on_item_double_clicked(func_index)
# Check signal was emitted
assert len(open_requested_signals) == 1
function_name, file_path = open_requested_signals[0]
assert function_name is not None
assert file_path is not None
def test_hover_events(self, macro_tree, qtbot):
"""Test mouse hover events and action button visibility."""
# Get the tree view and its viewport
tree_view = macro_tree.tree
viewport = tree_view.viewport()
# Initially, no item should be hovered
assert not macro_tree.delegate.hovered_index.isValid()
# Find a function item to hover over
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Get the position of the function item
rect = tree_view.visualRect(func_index)
pos = rect.center()
# Simulate a mouse move event over the item
mouse_event = QMouseEvent(
QEvent.Type.MouseMove,
pos,
tree_view.mapToGlobal(pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# Send the event to the viewport
macro_tree.eventFilter(viewport, mouse_event)
qtbot.wait(100)
# Now, the hover index should be set
assert macro_tree.delegate.hovered_index.isValid()
assert macro_tree.delegate.hovered_index == func_index
# Simulate mouse leaving the viewport
leave_event = QEvent(QEvent.Type.Leave)
macro_tree.eventFilter(viewport, leave_event)
qtbot.wait(100)
# After leaving, no item should be hovered
assert not macro_tree.delegate.hovered_index.isValid()
def test_macro_open_action(self, macro_tree, qtbot):
"""Test the macro open action functionality."""
# Set up signal spy
open_requested_signals = []
def on_macro_open_requested(function_name, file_path):
open_requested_signals.append((function_name, file_path))
macro_tree.macro_open_requested.connect(on_macro_open_requested)
# Find a function item and set it as hovered
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Set the delegate's hovered index and current macro info
macro_tree.delegate.set_hovered_index(func_index)
func_data = func_item.data(Qt.ItemDataRole.UserRole)
macro_tree.delegate.current_macro_info = func_data
# Trigger the open action
macro_tree._on_macro_open_requested()
# Check signal was emitted
assert len(open_requested_signals) == 1
function_name, file_path = open_requested_signals[0]
assert function_name is not None
assert file_path is not None
class TestMacroTreeRefresh:
"""Test macro tree refresh functionality."""
def test_refresh(self, macro_tree, temp_macro_files):
"""Test refreshing the entire tree."""
# Get initial count
initial_count = macro_tree.model.rowCount()
# Add a new macro file
new_file = temp_macro_files / "new_macros.py"
new_file.write_text('''
def new_function():
"""A new function."""
return "new"
''')
# Refresh the tree
macro_tree.refresh()
# Should have one more file
assert macro_tree.model.rowCount() == initial_count + 1
def test_refresh_file_item(self, macro_tree, temp_macro_files):
"""Test refreshing a single file item."""
# Find the test_macros.py file
test_file_path = str(temp_macro_files / "test_macros.py")
# Get initial function count
initial_functions = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data and item_data.get("file_path") == test_file_path:
for child_row in range(item.rowCount()):
child = item.child(child_row)
initial_functions.append(child.text())
break
# Modify the file to add a new function
with open(test_file_path, "a") as f:
f.write('''
def newly_added_function():
"""A newly added function."""
return "added"
''')
# Refresh just this file
macro_tree.refresh_file_item(test_file_path)
# Check that the new function was added
updated_functions = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data and item_data.get("file_path") == test_file_path:
for child_row in range(item.rowCount()):
child = item.child(child_row)
updated_functions.append(child.text())
break
# Should have the new function
assert len(updated_functions) == len(initial_functions) + 1
assert "newly_added_function" in updated_functions
def test_refresh_nonexistent_file(self, macro_tree):
"""Test refreshing a non-existent file."""
# Should handle gracefully without crashing
macro_tree.refresh_file_item("/nonexistent/file.py")
# Tree should remain unchanged
assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash
def test_expand_collapse_all(self, macro_tree, qtbot):
"""Test expand/collapse all functionality."""
# Initially should be expanded
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
# Items with children should be expanded after initial load
if item.rowCount() > 0:
assert macro_tree.tree.isExpanded(item.index())
# Collapse all
macro_tree.collapse_all()
qtbot.wait(50)
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item and item.rowCount() > 0:
assert not macro_tree.tree.isExpanded(item.index())
# Expand all
macro_tree.expand_all()
qtbot.wait(50)
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item and item.rowCount() > 0:
assert macro_tree.tree.isExpanded(item.index())
class TestMacroItemDelegate:
"""Test the custom macro item delegate functionality."""
def test_delegate_action_management(self, qtbot):
"""Test adding and clearing delegate actions."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Should have at least one default action (open)
assert len(widget.delegate.macro_actions) >= 1
# Add a custom action
custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget)
widget.add_macro_action(custom_action.action)
# Should have the additional action
assert len(widget.delegate.macro_actions) >= 2
# Clear actions
widget.clear_actions()
# Should be empty
assert len(widget.delegate.macro_actions) == 0
def test_delegate_hover_index_management(self, qtbot):
"""Test hover index management in the delegate."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Initially no hover
assert not widget.delegate.hovered_index.isValid()
# Create a fake index
fake_index = widget.model.createIndex(0, 0)
# Set hover
widget.delegate.set_hovered_index(fake_index)
assert widget.delegate.hovered_index == fake_index
# Clear hover
widget.delegate.set_hovered_index(QModelIndex())
assert not widget.delegate.hovered_index.isValid()
+39
View File
@@ -85,6 +85,45 @@ class TestFocusEditor:
assert editor1 != editor2
assert editor2 is not None
def test_close_file_force_discards_unsaved_changes(
self, qtbot, monaco_dock: MonacoDock, tmpdir
):
"""Test force-closing an open file bypasses the save prompt."""
file_path = tmpdir.join("force_close.py")
file_path.write("print('before')")
monaco_dock.open_file(str(file_path))
qtbot.wait(300)
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('after')")
qtbot.wait(100)
assert editor_widget.modified
with mock.patch.object(QMessageBox, "question") as mock_question:
assert monaco_dock.close_file(str(file_path), force=True)
assert not mock_question.called
assert editor_widget.current_file is None
assert editor_widget.get_text() == ""
def test_rename_open_path_updates_open_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test renaming an open file updates the tracked editor path and tooltip."""
file_path = tmpdir.join("rename_me.py")
file_path.write("print('before')")
monaco_dock.open_file(str(file_path))
qtbot.wait(300)
new_file_path = str(tmpdir.join("renamed.py"))
monaco_dock.rename_open_path(str(file_path), new_file_path)
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
assert editor_widget.current_file == new_file_path
assert monaco_dock._get_open_files() == [new_file_path]
class TestSaveFiles:
def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir):
-114
View File
@@ -1,114 +0,0 @@
from pathlib import Path
import pytest
from qtpy.QtCore import QEvent, Qt
from qtpy.QtGui import QMouseEvent
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
@pytest.fixture
def script_tree(qtbot, tmpdir):
"""Create a ScriptTreeWidget with the tmpdir directory"""
# Create test files and directories
(Path(tmpdir) / "test_file.py").touch()
(Path(tmpdir) / "test_dir").mkdir()
(Path(tmpdir) / "test_dir" / "nested_file.py").touch()
widget = ScriptTreeWidget()
widget.set_directory(str(tmpdir))
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_script_tree_set_directory(script_tree, tmpdir):
"""Test setting the directory"""
assert script_tree.directory == str(tmpdir)
def test_script_tree_hover_events(script_tree, qtbot):
"""Test mouse hover events and actions button visibility"""
# Get the tree view and its viewport
tree_view = script_tree.tree
viewport = tree_view.viewport()
# Find the position of the first item (test_file.py)
index = script_tree.proxy_model.index(0, 0) # first item
rect = tree_view.visualRect(index)
pos = rect.center()
# Initially, no item should be hovered
assert script_tree.delegate.hovered_index.isValid() == False
# Simulate a mouse move event over the item
mouse_event = QMouseEvent(
QEvent.Type.MouseMove,
pos,
tree_view.mapToGlobal(pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# Send the event to the viewport (the event filter is installed on the viewport)
script_tree.eventFilter(viewport, mouse_event)
# Now, the hover index should be set to the first item
qtbot.waitUntil(lambda: script_tree.delegate.hovered_index.isValid(), timeout=5000)
assert script_tree.delegate.hovered_index.row() == index.row()
# Simulate mouse leaving the viewport
leave_event = QEvent(QEvent.Type.Leave)
script_tree.eventFilter(viewport, leave_event)
# After leaving, no item should be hovered
qtbot.waitUntil(lambda: not script_tree.delegate.hovered_index.isValid(), timeout=5000)
@pytest.mark.timeout(10)
def test_script_tree_on_item_clicked(script_tree, qtbot, tmpdir):
"""Test that _on_item_clicked emits file_selected signal only for Python files"""
file_selected_signals = []
file_open_requested_signals = []
def on_file_selected(file_path):
file_selected_signals.append(file_path)
def on_file_open_requested(file_path):
file_open_requested_signals.append(file_path)
# Connect to the signal
script_tree.file_selected.connect(on_file_selected)
script_tree.file_open_requested.connect(on_file_open_requested)
# Wait until the model sees test_file.py
def has_py_file():
nonlocal py_file_index
root_index = script_tree.tree.rootIndex()
for i in range(script_tree.proxy_model.rowCount(root_index)):
index = script_tree.proxy_model.index(i, 0, root_index)
source_index = script_tree.proxy_model.mapToSource(index)
if script_tree.model.fileName(source_index) == "test_file.py":
py_file_index = index
return True
return False
py_file_index = None
qtbot.waitUntil(has_py_file)
# Simulate clicking on the center of the item
script_tree._on_item_clicked(py_file_index)
qtbot.wait(100) # Allow time for the click to be processed
py_file_index = None
qtbot.waitUntil(has_py_file)
script_tree._on_item_double_clicked(py_file_index)
qtbot.wait(100)
# Verify the signal was emitted with the correct path
assert len(file_selected_signals) == 1
assert Path(file_selected_signals[0]).name == "test_file.py"