Files
bec_widgets/tests/unit_tests/test_ide_explorer.py
T

723 lines
26 KiB
Python

import os
from pathlib import Path
from unittest import mock
import pytest
from qtpy.QtCore import QItemSelectionModel
from qtpy.QtWidgets import QMessageBox
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@pytest.fixture
def ide_explorer(qtbot, tmpdir):
"""Create an IDEExplorer widget for testing"""
widget = IDEExplorer()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_ide_explorer_initialization(ide_explorer):
"""Test the initialization of the IDEExplorer widget"""
assert ide_explorer is not None
assert "scripts" in ide_explorer.sections
assert ide_explorer.main_explorer.sections[0].title == "SCRIPTS"
def test_script_section_has_info_tooltip(ide_explorer):
"""Scripts section exposes click-to-open help on the info icon."""
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert not scripts_section.header_button.toolTip()
assert not scripts_section.header_info_button.isHidden()
assert scripts_section.header_info_button.isEnabled()
assert scripts_section.header_info_button.toolTip() == "Show help"
def test_macro_section_has_info_tooltip(ide_explorer):
"""Macros section exposes click-to-open help on the info icon."""
macros_section = ide_explorer.main_explorer.get_section("MACROS")
assert not macros_section.header_button.toolTip()
assert not macros_section.header_info_button.isHidden()
assert macros_section.header_info_button.isEnabled()
assert macros_section.header_info_button.toolTip() == "Show help"
def test_script_section_info_button_opens_help_popup(ide_explorer, qtbot):
"""Clicking the info button should show the styled help popup."""
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
scripts_section.header_info_button.click()
qtbot.waitUntil(
lambda: scripts_section._help_tooltip is not None
and scripts_section._help_tooltip.isVisible(),
timeout=1000,
)
assert scripts_section._help_tooltip.content.text().startswith("Scripts are executable")
scripts_section._cleanup_help_tooltip()
def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir):
local_script_section = ide_explorer.main_explorer.get_section(
"SCRIPTS"
).content_widget.get_section("Local")
local_script_section.content_widget.set_directory(str(tmpdir))
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("test_file.py", True),
):
ide_explorer._add_local_script()
assert os.path.exists(os.path.join(tmpdir, "test_file.py"))
def test_shared_scripts_section_with_files(ide_explorer, tmpdir):
"""Test that shared scripts section is created when plugin directory has files"""
# Create dummy shared script files
shared_scripts_dir = tmpdir.mkdir("shared_scripts")
shared_scripts_dir.join("shared_script1.py").write("# Shared script 1")
shared_scripts_dir.join("shared_script2.py").write("# Shared script 2")
ide_explorer.clear()
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = str(shared_scripts_dir)
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should have both Local and Shared sections
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is not None
assert "read-only" in shared_section.toolTip().lower()
def test_shared_macros_section_with_files(ide_explorer, tmpdir):
"""Test that shared macros section is created when plugin directory has files"""
# Create dummy shared macro files
shared_macros_dir = tmpdir.mkdir("shared_macros")
shared_macros_dir.join("shared_macro1.py").write("""
def shared_function1():
return "shared1"
def shared_function2():
return "shared2"
""")
shared_macros_dir.join("utilities.py").write("""
def utility_function():
return "utility"
""")
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = str(shared_macros_dir)
ide_explorer.clear()
ide_explorer.sections = ["macros"]
macros_section = ide_explorer.main_explorer.get_section("MACROS")
assert macros_section is not None
# Should have both Local and Shared sections
local_section = macros_section.content_widget.get_section("Local")
shared_section = macros_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is not None
assert "read-only" in shared_section.toolTip().lower()
def test_shared_sections_not_added_when_plugin_dir_missing(ide_explorer):
"""Test that shared sections are not added when plugin directories don't exist"""
ide_explorer.clear()
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = None
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should only have Local section
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is None
def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir):
"""Test that shared sections are not added when plugin directory doesn't exist on disk"""
ide_explorer.clear()
# Return a path that doesn't exist
nonexistent_path = str(tmpdir.join("nonexistent"))
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = nonexistent_path
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should only have Local section since directory doesn't exist
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is None
def test_file_selection_highlight_is_global_across_scripts_and_macros(ide_explorer, qtbot, tmpdir):
"""Selecting a script file clears the previous macro file highlight."""
scripts_dir = tmpdir.mkdir("scripts")
macros_dir = tmpdir.mkdir("macros")
script_file = scripts_dir.join("script.py")
macro_file = macros_dir.join("macro.py")
script_file.write("print('script')")
macro_file.write("def macro():\n pass\n")
scripts_browser = (
ide_explorer.main_explorer.get_section("SCRIPTS")
.content_widget.get_section("Local")
.content_widget
)
macros_browser = (
ide_explorer.main_explorer.get_section("MACROS")
.content_widget.get_section("Local")
.content_widget
)
scripts_browser.set_directory(str(scripts_dir))
macros_browser.set_directory(str(macros_dir))
def get_index(browser, file_name):
root_index = browser.tree.rootIndex()
for i in range(browser.proxy_model.rowCount(root_index)):
index = browser.proxy_model.index(i, 0, root_index)
if browser.proxy_model.data(index) == file_name:
return index
return None
qtbot.waitUntil(
lambda: get_index(scripts_browser, "script.py") is not None
and get_index(macros_browser, "macro.py") is not None,
timeout=5000,
)
macro_index = get_index(macros_browser, "macro.py")
assert macro_index is not None
macros_browser.tree.setCurrentIndex(macro_index)
macros_browser.tree.selectionModel().select(
macro_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
macros_browser.file_selected.emit(str(macro_file))
assert macros_browser.tree.selectionModel().hasSelection()
script_index = get_index(scripts_browser, "script.py")
assert script_index is not None
scripts_browser.tree.setCurrentIndex(script_index)
scripts_browser.tree.selectionModel().select(
script_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
scripts_browser.file_selected.emit(str(script_file))
assert scripts_browser.tree.selectionModel().hasSelection()
assert not macros_browser.tree.selectionModel().hasSelection()
def test_modifier_file_selection_keeps_existing_highlights(ide_explorer, qtbot, tmpdir):
"""Modifier-click selection keeps earlier highlights across scripts and macros."""
scripts_dir = tmpdir.mkdir("scripts")
macros_dir = tmpdir.mkdir("macros")
script_file = scripts_dir.join("script.py")
macro_file = macros_dir.join("macro.py")
script_file.write("print('script')")
macro_file.write("def macro():\n pass\n")
scripts_browser = (
ide_explorer.main_explorer.get_section("SCRIPTS")
.content_widget.get_section("Local")
.content_widget
)
macros_browser = (
ide_explorer.main_explorer.get_section("MACROS")
.content_widget.get_section("Local")
.content_widget
)
scripts_browser.set_directory(str(scripts_dir))
macros_browser.set_directory(str(macros_dir))
def get_index(browser, file_name):
root_index = browser.tree.rootIndex()
for i in range(browser.proxy_model.rowCount(root_index)):
index = browser.proxy_model.index(i, 0, root_index)
if browser.proxy_model.data(index) == file_name:
return index
return None
qtbot.waitUntil(
lambda: get_index(scripts_browser, "script.py") is not None
and get_index(macros_browser, "macro.py") is not None,
timeout=5000,
)
macro_index = get_index(macros_browser, "macro.py")
assert macro_index is not None
macros_browser.tree.selectionModel().select(
macro_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
macros_browser.file_selected.emit(str(macro_file))
script_index = get_index(scripts_browser, "script.py")
assert script_index is not None
scripts_browser.tree.selectionModel().select(
script_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
scripts_browser._selection_extending = True
scripts_browser.file_selected.emit(str(script_file))
assert scripts_browser.tree.selectionModel().hasSelection()
assert macros_browser.tree.selectionModel().hasSelection()
@pytest.mark.parametrize(
"slot, signal, file_name,scope",
[
(
"_emit_file_open_scripts_local",
"file_open_requested",
"example_script.py",
"scripts/local",
),
(
"_emit_file_preview_scripts_local",
"file_preview_requested",
"example_macro.py",
"scripts/local",
),
(
"_emit_file_open_scripts_shared",
"file_open_requested",
"example_script.py",
"scripts/shared",
),
(
"_emit_file_preview_scripts_shared",
"file_preview_requested",
"example_macro.py",
"scripts/shared",
),
],
)
def test_ide_explorer_file_signals(ide_explorer, qtbot, slot, signal, file_name, scope):
"""Test that the correct signals are emitted when files are opened or previewed"""
recv = []
def recv_file_signal(file_name, scope):
recv.append((file_name, scope))
sig = getattr(ide_explorer, signal)
sig.connect(recv_file_signal)
# Call the appropriate slot
getattr(ide_explorer, slot)(file_name)
qtbot.wait(300)
# Verify the signal was emitted with correct arguments
assert recv == [(file_name, scope)]
@pytest.mark.parametrize(
"slot, signal, func_name, file_path,scope",
[
(
"_emit_file_open_macros_local",
"file_open_requested",
"example_macro_function",
"macros/local/example_macro.py",
"macros/local",
),
(
"_emit_file_preview_macros_local",
"file_preview_requested",
"example_macro_function",
"macros/local/example_macro.py",
"macros/local",
),
(
"_emit_file_open_macros_shared",
"file_open_requested",
"example_macro_function",
"macros/shared/example_macro.py",
"macros/shared",
),
(
"_emit_file_preview_macros_shared",
"file_preview_requested",
"example_macro_function",
"macros/shared/example_macro.py",
"macros/shared",
),
],
)
def test_ide_explorer_file_signals_macros(
ide_explorer, qtbot, slot, signal, func_name, file_path, scope
):
"""Test that the correct signals are emitted when macro files are opened or previewed"""
recv = []
def recv_file_signal(file_name, scope):
recv.append((file_name, scope))
sig = getattr(ide_explorer, signal)
sig.connect(recv_file_signal)
# Call the appropriate slot
getattr(ide_explorer, slot)(func_name, file_path)
qtbot.wait(300)
# Verify the signal was emitted with correct arguments
assert recv == [(file_path, scope)]
def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro through the UI"""
# Create macros section first
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("test_macro_function", True),
):
ide_explorer._add_local_macro()
# Check that the macro file was created
expected_file = os.path.join(tmpdir, "test_macro_function.py")
assert os.path.exists(expected_file)
# Check that the file contains the expected function
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()
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))
# Test with invalid function name (starts with number)
with (
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("123invalid", True),
),
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.warning"
) as mock_warning,
):
ide_explorer._add_local_macro()
# Should show warning message
mock_warning.assert_called_once()
# Should not create any file
assert len(os.listdir(tmpdir)) == 0
def test_ide_explorer_add_local_macro_file_exists(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro when file already exists"""
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))
# Create an existing file
existing_file = Path(tmpdir) / "existing_macro.py"
existing_file.write_text("# Existing macro")
with (
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("existing_macro", True),
),
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes,
) as mock_question,
):
ide_explorer._add_local_macro()
# Should ask for overwrite confirmation
mock_question.assert_called_once()
# File should be overwritten with new content
with open(existing_file, "r") as f:
content = f.read()
assert "def existing_macro():" in content
def test_ide_explorer_add_local_macro_cancelled(ide_explorer, qtbot, tmpdir):
"""Test cancelling the add local macro dialog"""
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))
# User cancels the dialog
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("", False), # User cancelled
):
ide_explorer._add_local_macro()
# Should not create any file
assert len(os.listdir(tmpdir)) == 0
def test_ide_explorer_reload_macros_success(ide_explorer, qtbot):
"""Test successful macro reloading"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Mock the client and macros
mock_client = mock.MagicMock()
mock_macros = mock.MagicMock()
mock_client.macros = mock_macros
ide_explorer.client = mock_client
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.information"
) as mock_info:
ide_explorer._reload_macros()
# Should call load_all_user_macros
mock_macros.load_all_user_macros.assert_called_once()
# Should show success message
mock_info.assert_called_once()
assert "successfully" in mock_info.call_args[0][2]
def test_ide_explorer_reload_macros_error(ide_explorer, qtbot):
"""Test macro reloading when an error occurs"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Mock client with macros that raises an exception
mock_client = mock.MagicMock()
mock_macros = mock.MagicMock()
mock_macros.load_all_user_macros.side_effect = Exception("Test error")
mock_client.macros = mock_macros
ide_explorer.client = mock_client
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.critical"
) as mock_critical:
ide_explorer._reload_macros()
# Should show error message
mock_critical.assert_called_once()
assert "Failed to reload macros" in mock_critical.call_args[0][2]
def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir):
"""Test refreshing a local macro file"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Create a test macro file
macro_file = Path(tmpdir) / "test_macro.py"
macro_file.write_text("def test_function(): pass")
# 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 refresh the file browser
mock_refresh.assert_called_once_with()
def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir):
"""Test refreshing a macro file that doesn't match any directory"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Try to refresh a file that's not in any macro directory
unrelated_file = "/some/other/path/unrelated.py"
# 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 refresh for unrelated files
mock_refresh.assert_not_called()
def test_ide_explorer_refresh_macro_file_no_sections(ide_explorer, qtbot):
"""Test refreshing a macro file when no macro sections exist"""
ide_explorer.clear()
# Don't add macros section
# Should handle gracefully without error
ide_explorer.refresh_macro_file("/some/path/test.py")
# Test passes if no exception is raised