From 0d0eb1d8ee036c8d684449dc74c2d0f01829096d Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 8 Oct 2025 17:42:41 +0200 Subject: [PATCH] fix: developer view improvements and dependency updates --- bec_widgets/cli/client.py | 73 ++- .../developer_view/developer_widget.py | 74 +-- .../containers/explorer/explorer_delegate.py | 125 ++++ .../containers/explorer/macro_tree_widget.py | 99 +--- .../containers/explorer/script_tree_widget.py | 138 +---- .../monaco/{monaco_tab.py => monaco_dock.py} | 96 +-- .../widgets/editors/monaco/monaco_widget.py | 41 +- .../utility/ide_explorer/ide_explorer.py | 46 +- pyproject.toml | 5 +- .../test_collapsible_tree_section.py | 119 ++++ tests/unit_tests/test_developer_view.py | 378 ++++++++++++ tests/unit_tests/test_ide_explorer.py | 422 ++++++++++++++ tests/unit_tests/test_macro_tree_widget.py | 548 ++++++++++++++++++ tests/unit_tests/test_monaco_dock.py | 425 ++++++++++++++ tests/unit_tests/test_monaco_editor.py | 87 ++- tests/unit_tests/test_tree_widget.py | 22 - 16 files changed, 2335 insertions(+), 363 deletions(-) create mode 100644 bec_widgets/widgets/containers/explorer/explorer_delegate.py rename bec_widgets/widgets/editors/monaco/{monaco_tab.py => monaco_dock.py} (85%) create mode 100644 tests/unit_tests/test_collapsible_tree_section.py create mode 100644 tests/unit_tests/test_developer_view.py create mode 100644 tests/unit_tests/test_macro_tree_widget.py create mode 100644 tests/unit_tests/test_monaco_dock.py delete mode 100644 tests/unit_tests/test_tree_widget.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 63e835ba..dda269f0 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2686,26 +2686,54 @@ class LogPanel(RPCBase): class Minesweeper(RPCBase): ... +class MonacoDock(RPCBase): + """MonacoDock is a dock widget that contains Monaco editor instances.""" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + class MonacoWidget(RPCBase): """A simple Monaco editor widget""" @rpc_call - def set_text(self, text: str) -> None: + def set_text( + self, text: "str", file_name: "str | None" = None, reset: "bool" = False + ) -> "None": """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ @rpc_call - def get_text(self) -> str: + def get_text(self) -> "str": """ Get the current text from the Monaco editor. """ @rpc_call - def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: + def insert_text( + self, text: "str", line: "int | None" = None, column: "int | None" = None + ) -> "None": """ Insert text at the current cursor position or at a specified line and column. @@ -2716,7 +2744,7 @@ class MonacoWidget(RPCBase): """ @rpc_call - def delete_line(self, line: int | None = None) -> None: + def delete_line(self, line: "int | None" = None) -> "None": """ Delete a line in the Monaco editor. @@ -2725,7 +2753,16 @@ class MonacoWidget(RPCBase): """ @rpc_call - def set_language(self, language: str) -> None: + def open_file(self, file_name: "str") -> "None": + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + @rpc_call + def set_language(self, language: "str") -> "None": """ Set the programming language for syntax highlighting in the Monaco editor. @@ -2734,13 +2771,13 @@ class MonacoWidget(RPCBase): """ @rpc_call - def get_language(self) -> str: + def get_language(self) -> "str": """ Get the current programming language set in the Monaco editor. """ @rpc_call - def set_theme(self, theme: str) -> None: + def set_theme(self, theme: "str") -> "None": """ Set the theme for the Monaco editor. @@ -2749,13 +2786,13 @@ class MonacoWidget(RPCBase): """ @rpc_call - def get_theme(self) -> str: + def get_theme(self) -> "str": """ Get the current theme of the Monaco editor. """ @rpc_call - def set_readonly(self, read_only: bool) -> None: + def set_readonly(self, read_only: "bool") -> "None": """ Set the Monaco editor to read-only mode. @@ -2766,10 +2803,10 @@ class MonacoWidget(RPCBase): @rpc_call def set_cursor( self, - line: int, - column: int = 1, - move_to_position: Literal[None, "center", "top", "position"] = None, - ) -> None: + line: "int", + column: "int" = 1, + move_to_position: "Literal[None, 'center', 'top', 'position']" = None, + ) -> "None": """ Set the cursor position in the Monaco editor. @@ -2780,7 +2817,7 @@ class MonacoWidget(RPCBase): """ @rpc_call - def current_cursor(self) -> dict[str, int]: + def current_cursor(self) -> "dict[str, int]": """ Get the current cursor position in the Monaco editor. @@ -2789,7 +2826,7 @@ class MonacoWidget(RPCBase): """ @rpc_call - def set_minimap_enabled(self, enabled: bool) -> None: + def set_minimap_enabled(self, enabled: "bool") -> "None": """ Enable or disable the minimap in the Monaco editor. @@ -2798,7 +2835,7 @@ class MonacoWidget(RPCBase): """ @rpc_call - def set_vim_mode_enabled(self, enabled: bool) -> None: + def set_vim_mode_enabled(self, enabled: "bool") -> "None": """ Enable or disable Vim mode in the Monaco editor. @@ -2807,7 +2844,7 @@ class MonacoWidget(RPCBase): """ @rpc_call - def set_lsp_header(self, header: str) -> None: + def set_lsp_header(self, header: "str") -> "None": """ Set the LSP (Language Server Protocol) header for the Monaco editor. The header is used to provide context for language servers but is not displayed in the editor. @@ -2817,7 +2854,7 @@ class MonacoWidget(RPCBase): """ @rpc_call - def get_lsp_header(self) -> str: + def get_lsp_header(self) -> "str": """ Get the current LSP header set in the Monaco editor. diff --git a/bec_widgets/examples/developer_view/developer_widget.py b/bec_widgets/examples/developer_view/developer_widget.py index 25061174..9e16bceb 100644 --- a/bec_widgets/examples/developer_view/developer_widget.py +++ b/bec_widgets/examples/developer_view/developer_widget.py @@ -1,21 +1,22 @@ import re import markdown -import PySide6QtAds as QtAds from bec_lib.endpoints import MessageEndpoints from bec_lib.script_executor import upload_script from bec_qthemes import material_icon -from PySide6QtAds import CDockManager, CDockWidget from qtpy.QtGui import QKeySequence, QShortcut from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget +from shiboken6 import isValid +import bec_widgets.widgets.containers.ads as QtAds from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.ads import CDockManager, CDockWidget from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -110,6 +111,7 @@ class DeveloperWidget(BECWidget, QWidget): self.monaco.signature_help.connect( lambda text: self.signature_help.setHtml(markdown_to_html(text)) ) + self._current_script_id: str | None = None # Create the dock widgets self.explorer_dock = QtAds.CDockWidget("Explorer", self) @@ -141,9 +143,9 @@ class DeveloperWidget(BECWidget, QWidget): for dock in self.dock_manager.dockWidgets(): # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same - dock.setFeature(CDockWidget.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, False) self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) self.plotting_ads_dock.setWidget(self.plotting_ads) @@ -174,6 +176,7 @@ class DeveloperWidget(BECWidget, QWidget): icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self ) self.toolbar.components.add_safe("save_as", save_as_button) + save_as_button.action.triggered.connect(self.on_save_as) save_bundle = ToolbarBundle("save", self.toolbar.components) save_bundle.add_action("save") @@ -272,29 +275,30 @@ class DeveloperWidget(BECWidget, QWidget): @SafeSlot() def on_stop(self): - print("Stopping execution...") + if not self.current_script_id: + return + self.console.send_ctrl_c() @property def current_script_id(self): return self._current_script_id @current_script_id.setter - def current_script_id(self, value): - if not isinstance(value, str): + def current_script_id(self, value: str | None): + if value is not None and not isinstance(value, str): raise ValueError("Script ID must be a string.") + old_script_id = self._current_script_id self._current_script_id = value - self._update_subscription() + self._update_subscription(value, old_script_id) - def _update_subscription(self): - if self.current_script_id: - self.bec_dispatcher.connect_slot( - self.on_script_execution_info, - MessageEndpoints.script_execution_info(self.current_script_id), - ) - else: + def _update_subscription(self, new_script_id: str | None, old_script_id: str | None): + if old_script_id is not None: self.bec_dispatcher.disconnect_slot( - self.on_script_execution_info, - MessageEndpoints.script_execution_info(self.current_script_id), + self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id) + ) + if new_script_id is not None: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id) ) @SafeSlot(dict, dict) @@ -308,6 +312,20 @@ class DeveloperWidget(BECWidget, QWidget): self.script_editor_tab.widget().clear_highlighted_lines() self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + def cleanup(self): + for dock in self.dock_manager.dockWidgets(): + self._delete_dock(dock) + return super().cleanup() + + def _delete_dock(self, dock: CDockWidget) -> None: + w = dock.widget() + if w and isValid(w): + w.close() + w.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + if __name__ == "__main__": import sys @@ -321,24 +339,6 @@ if __name__ == "__main__": apply_theme("dark") _app = BECMainApp() - screen = app.primaryScreen() - screen_geometry = screen.availableGeometry() - screen_width = screen_geometry.width() - screen_height = screen_geometry.height() - # 70% of screen height, keep 16:9 ratio - height = int(screen_height * 0.9) - width = int(height * (16 / 9)) - - # If width exceeds screen width, scale down - if width > screen_width * 0.9: - width = int(screen_width * 0.9) - height = int(width / (16 / 9)) - - _app.resize(width, height) - developer_view = DeveloperView() - _app.add_view( - icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True - ) _app.show() # developer_view.show() # developer_view.setWindowTitle("Developer View") diff --git a/bec_widgets/widgets/containers/explorer/explorer_delegate.py b/bec_widgets/widgets/containers/explorer/explorer_delegate.py new file mode 100644 index 00000000..7a8b41cb --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/explorer_delegate.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt +from qtpy.QtGui import QPainter +from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView + +from bec_widgets.utils.colors import get_theme_palette + + +class ExplorerDelegate(QStyledItemDelegate): + """Custom delegate to show action buttons on hover for the explorer""" + + def __init__(self, parent=None): + super().__init__(parent) + self.hovered_index = QModelIndex() + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + self.target_model = QSortFilterProxyModel + + def paint(self, painter, option, index): + """Paint the item with action buttons on hover""" + # Paint the default item + super().paint(painter, option, index) + + # Early return if not hovering over this item + if index != self.hovered_index: + return + + tree_view = self.parent() + if not isinstance(tree_view, QTreeView): + return + + proxy_model = tree_view.model() + if not isinstance(proxy_model, self.target_model): + return + + actions = self.get_actions_for_current_item(proxy_model, index) + if actions: + self._draw_action_buttons(painter, option, actions) + + def _draw_action_buttons(self, painter, option, actions: list[Any]): + """Draw action buttons on the right side""" + button_size = 18 + margin = 4 + spacing = 2 + + # Calculate total width needed for all buttons + total_width = len(actions) * button_size + (len(actions) - 1) * spacing + + # Clear previous button rects and create new ones + self.button_rects.clear() + + # Calculate starting position (right side of the item) + start_x = option.rect.right() - total_width - margin + current_x = start_x + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get theme colors for better integration + palette = get_theme_palette() + button_bg = palette.button().color() + button_bg.setAlpha(150) # Semi-transparent + + for action in actions: + if not action.isVisible(): + continue + + # Calculate button position + button_rect = QRect( + current_x, + option.rect.top() + (option.rect.height() - button_size) // 2, + button_size, + button_size, + ) + self.button_rects.append(button_rect) + + # Draw button background + painter.setBrush(button_bg) + painter.setPen(palette.mid().color()) + painter.drawRoundedRect(button_rect, 3, 3) + + # Draw action icon + icon = action.icon() + if not icon.isNull(): + icon_rect = button_rect.adjusted(2, 2, -2, -2) + icon.paint(painter, icon_rect) + + # Move to next button position + current_x += button_size + spacing + + painter.restore() + + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: + """Get actions for the current item based on its type""" + return None + + def editorEvent(self, event, model, option, index): + """Handle mouse events for action buttons""" + # Early return if not a left click + if not ( + event.type() == event.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + return super().editorEvent(event, model, option, index) + + actions = self.get_actions_for_current_item(model, index) + if not actions: + return super().editorEvent(event, model, option, index) + + # Check which button was clicked + visible_actions = [action for action in actions if action.isVisible()] + for i, button_rect in enumerate(self.button_rects): + if button_rect.contains(event.pos()) and i < len(visible_actions): + # Trigger the action + visible_actions[i].trigger() + return True + + return super().editorEvent(event, model, option, index) + + def set_hovered_index(self, index): + """Set the currently hovered index""" + self.hovered_index = index diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py index be088508..2546eb35 100644 --- a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -5,24 +5,25 @@ from typing import Any from bec_lib.logger import bec_logger from qtpy.QtCore import QModelIndex, QRect, Qt, Signal -from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel -from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate logger = bec_logger.logger -class MacroItemDelegate(QStyledItemDelegate): +class MacroItemDelegate(ExplorerDelegate): """Custom delegate to show action buttons on hover for macro functions""" def __init__(self, parent=None): super().__init__(parent) - self.hovered_index = QModelIndex() self.macro_actions: list[Any] = [] self.button_rects: list[QRect] = [] self.current_macro_info = {} + self.target_model = QStandardItemModel def add_macro_action(self, action: Any) -> None: """Add an action for macro functions""" @@ -32,15 +33,7 @@ class MacroItemDelegate(QStyledItemDelegate): """Remove all actions""" self.macro_actions.clear() - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: # Only show actions for macro functions (not directories) item = index.model().itemFromIndex(index) if not item or not item.data(Qt.ItemDataRole.UserRole): @@ -51,85 +44,7 @@ class MacroItemDelegate(QStyledItemDelegate): return self.current_macro_info = macro_info - - if self.macro_actions: - self._draw_action_buttons(painter, option, self.macro_actions) - - def _draw_action_buttons(self, painter, option, actions: list[Any]): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Check which button was clicked - visible_actions = [action for action in self.macro_actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index + return self.macro_actions class MacroTreeWidget(QWidget): diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 6c8ed5c8..68ff1035 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -2,27 +2,23 @@ import os from pathlib import Path from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal -from qtpy.QtGui import QPainter -from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal +from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate logger = bec_logger.logger -class FileItemDelegate(QStyledItemDelegate): +class FileItemDelegate(ExplorerDelegate): """Custom delegate to show action buttons on hover""" def __init__(self, tree_widget): super().__init__(tree_widget) - self.setObjectName("file_item_delegate") - self.hovered_index = QModelIndex() self.file_actions = [] self.dir_actions = [] - self.button_rects = [] - self.current_file_path = "" def add_file_action(self, action) -> None: """Add an action for files""" @@ -37,126 +33,18 @@ class FileItemDelegate(QStyledItemDelegate): self.file_actions.clear() self.dir_actions.clear() - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - - tree_view = self.parent() - if not isinstance(tree_view, QTreeView): - return - - proxy_model = tree_view.model() - if not isinstance(proxy_model, QSortFilterProxyModel): - return - - source_index = proxy_model.mapToSource(index) - source_model = proxy_model.sourceModel() - if not isinstance(source_model, QFileSystemModel): - return - - is_dir = source_model.isDir(source_index) - file_path = source_model.filePath(source_index) - self.current_file_path = file_path - - # Choose appropriate actions based on item type - actions = self.dir_actions if is_dir else self.file_actions - if actions: - self._draw_action_buttons(painter, option, actions) - - def _draw_action_buttons(self, painter, option, actions): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Early return if not a proxy model + def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None: + """Get actions for the current item based on its type""" if not isinstance(model, QSortFilterProxyModel): - return super().editorEvent(event, model, option, index) + return None source_index = model.mapToSource(index) source_model = model.sourceModel() - - # Early return if not a file system model if not isinstance(source_model, QFileSystemModel): - return super().editorEvent(event, model, option, index) + return None is_dir = source_model.isDir(source_index) - actions = self.dir_actions if is_dir else self.file_actions - - # Check which button was clicked - visible_actions = [action for action in actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index + return self.dir_actions if is_dir else self.file_actions class ScriptTreeWidget(QWidget): @@ -293,14 +181,14 @@ class ScriptTreeWidget(QWidget): return super().eventFilter(obj, event) - def set_directory(self, directory): + def set_directory(self, directory: str) -> None: """Set the scripts directory""" - self.directory = directory - # Early return if directory doesn't exist - if not directory or not os.path.exists(directory): + if not directory or not isinstance(directory, str) or not os.path.exists(directory): return + self.directory = directory + root_index = self.model.setRootPath(directory) # Map the source model index to proxy model index proxy_root_index = self.proxy_model.mapFromSource(root_index) diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py similarity index 85% rename from bec_widgets/widgets/editors/monaco/monaco_tab.py rename to bec_widgets/widgets/editors/monaco/monaco_dock.py index a7332557..eaee6669 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_tab.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -2,16 +2,16 @@ from __future__ import annotations import os import pathlib -from typing import Any, Literal, cast +from typing import Any, cast -import PySide6QtAds as QtAds from bec_lib.logger import bec_logger from bec_lib.macro_update_handler import has_executable_code -from PySide6QtAds import CDockWidget from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget +import bec_widgets.widgets.containers.ads as QtAds from bec_widgets import BECWidget +from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger @@ -40,13 +40,14 @@ class MonacoDock(BECWidget, QWidget): self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) self._root_layout.addWidget(self.dock_manager) self.dock_manager.installEventFilter(self) - self._last_focused_editor: MonacoWidget | None = None + self._last_focused_editor: CDockWidget | None = None self.focused_editor.connect(self._on_last_focused_editor_changed) self.add_editor() self._open_files = {} def _create_editor(self): - widget = MonacoWidget(self) + init_lsp = len(self.dock_manager.dockWidgets()) == 0 + widget = MonacoWidget(self, init_lsp=init_lsp) widget.save_enabled.connect(self.save_enabled.emit) widget.editor.signature_help_triggered.connect(self._on_signature_change) count = len(self.dock_manager.dockWidgets()) @@ -58,11 +59,11 @@ class MonacoDock(BECWidget, QWidget): lambda modified: self._update_tab_title_for_modification(dock, modified) ) - dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) - dock.setFeature(CDockWidget.CustomCloseHandling, True) - dock.setFeature(CDockWidget.DockWidgetClosable, True) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, True) dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) @@ -73,6 +74,10 @@ class MonacoDock(BECWidget, QWidget): """ Get the last focused editor. """ + dock_widget = self.dock_manager.focusedDockWidget() + if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget): + self.last_focused_editor = dock_widget + return self._last_focused_editor @last_focused_editor.setter @@ -199,7 +204,7 @@ class MonacoDock(BECWidget, QWidget): def _scan_and_fix_areas(self): # Find all dock areas under this manager and ensure each single-tab area has a plus button - areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget) + areas = self.dock_manager.findChildren(CDockAreaWidget) for a in areas: self._ensure_area_plus(a) @@ -226,7 +231,9 @@ class MonacoDock(BECWidget, QWidget): if tooltip is not None: new_dock.setTabToolTip(tooltip) if area is None: - area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock) + area_obj = self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.TopDockWidgetArea, new_dock + ) self._ensure_area_plus(area_obj) else: # If an area is provided, add the dock to that area @@ -253,8 +260,13 @@ class MonacoDock(BECWidget, QWidget): # For now, the dock manager is only for the editor docks. We can therefore safely assume # that all docks are editor docks. dock_area = self.dock_manager.dockArea(0) + if not dock_area: + return editor_dock = dock_area.currentDockWidget() + if not editor_dock: + return + editor_widget = editor_dock.widget() if editor_dock else None if editor_widget: editor_widget = cast(MonacoWidget, editor_dock.widget()) @@ -262,14 +274,17 @@ class MonacoDock(BECWidget, QWidget): editor_dock.setWindowTitle(file) editor_dock.setTabToolTip(file_name) editor_widget.open_file(file_name) - editor_widget.metadata["scope"] = scope + if scope is not None: + editor_widget.metadata["scope"] = scope return # File is not open, create a new editor editor_dock = self.add_editor(title=file, tooltip=file_name) widget = cast(MonacoWidget, editor_dock.widget()) widget.open_file(file_name) - widget.metadata["scope"] = scope + if scope is not None: + widget.metadata["scope"] = scope + editor_dock.setAsCurrentTab() def save_file( self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True @@ -309,35 +324,36 @@ class MonacoDock(BECWidget, QWidget): # Save as option save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") - if save_file: - # check if we have suffix specified - file = pathlib.Path(save_file[0]) - if file.suffix == "": - file = file.with_suffix(".py") - if format_on_save and file.suffix == ".py": - widget.format() + if not save_file or not save_file[0]: + return + # check if we have suffix specified + file = pathlib.Path(save_file[0]) + if file.suffix == "": + file = file.with_suffix(".py") + if format_on_save and file.suffix == ".py": + widget.format() - text = widget.get_text() - with open(file, "w", encoding="utf-8") as f: - f.write(text) - widget._original_content = text + text = widget.get_text() + with open(file, "w", encoding="utf-8") as f: + f.write(text) + widget._original_content = text - # Update the current_file before emitting save_enabled to ensure proper tracking - widget._current_file = str(file) - widget.save_enabled.emit(False) + # Update the current_file before emitting save_enabled to ensure proper tracking + widget._current_file = str(file) + widget.save_enabled.emit(False) - # Find the dock widget containing this monaco widget and update title - for dock in self.dock_manager.dockWidgets(): - if dock.widget() == widget: - dock.setWindowTitle(file.name) - dock.setTabToolTip(str(file)) - break - if "macros" in widget.metadata.get("scope", ""): - self._update_macros(widget) - # Emit signal to refresh macro tree widget - self.macro_file_updated.emit(str(file)) + # Find the dock widget containing this monaco widget and update title + for dock in self.dock_manager.dockWidgets(): + if dock.widget() == widget: + dock.setWindowTitle(file.name) + dock.setTabToolTip(str(file)) + break + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(str(file)) - print(f"Save file called, last focused editor: {self.last_focused_editor}") + logger.debug(f"Save file called, last focused editor: {self.last_focused_editor}") def _validate_macros(self, source: str) -> bool: # pylint: disable=protected-access @@ -398,7 +414,7 @@ class MonacoDock(BECWidget, QWidget): open_files.append(editor_widget.current_file) return open_files - def _get_editor_dock(self, file_name: str) -> CDockWidget | None: + def _get_editor_dock(self, file_name: str) -> QtAds.CDockWidget | None: for widget in self.dock_manager.dockWidgets(): editor_widget = cast(MonacoWidget, widget.widget()) if editor_widget.current_file == file_name: diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index dbfc9d5a..25fd2b3d 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import os import traceback -from typing import Literal +from typing import TYPE_CHECKING, Literal import black import isort @@ -13,6 +15,9 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.error_popups import SafeSlot +if TYPE_CHECKING: + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + logger = bec_logger.logger @@ -47,7 +52,9 @@ class MonacoWidget(BECWidget, QWidget): "screenshot", ] - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs + ): super().__init__( parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs ) @@ -64,6 +71,16 @@ class MonacoWidget(BECWidget, QWidget): self._current_file = None self._original_content = "" self.metadata = {} + if init_lsp: + self.editor.update_workspace_configuration( + { + "pylsp": { + "plugins": { + "pylsp-bec": {"service_config": self.client._service_config.config} + } + } + } + ) @property def current_file(self): @@ -84,16 +101,18 @@ class MonacoWidget(BECWidget, QWidget): editor_theme = "vs" if theme == "light" else "vs-dark" self.set_theme(editor_theme) - def set_text(self, text: str, file_name: str | None = None) -> None: + def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> None: """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ - self._current_file = file_name - self._original_content = text + self._current_file = file_name if file_name else self._current_file + if reset: + self._original_content = text self.editor.set_text(text, uri=file_name) def get_text(self) -> str: @@ -161,7 +180,7 @@ class MonacoWidget(BECWidget, QWidget): with open(file_name, "r", encoding="utf-8") as file: content = file.read() - self.set_text(content, file_name=file_name) + self.set_text(content, file_name=file_name, reset=True) @property def modified(self) -> bool: @@ -311,6 +330,16 @@ class MonacoWidget(BECWidget, QWidget): from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog dialog = ScanControlDialog(self, client=self.client) + self._run_dialog_and_insert_code(dialog) + + def _run_dialog_and_insert_code(self, dialog: ScanControlDialog): + """ + Run the dialog and insert the generated scan code if accepted. + It is a separate method to allow easier testing. + + Args: + dialog (ScanControlDialog): The scan control dialog instance. + """ if dialog.exec_() == QDialog.DialogCode.Accepted: scan_code = dialog.get_scan_code() if scan_code: diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index bec91e44..d5eda4b5 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -3,6 +3,7 @@ import importlib import importlib.metadata import os import re +from typing import Literal from bec_qthemes import material_icon from qtpy.QtCore import Signal @@ -65,6 +66,17 @@ class IDEExplorer(BECWidget, QWidget): case _: pass + def _remove_section(self, section_name): + section = self.main_explorer.get_section(section_name.upper()) + if section: + self.main_explorer.remove_section(section) + self._sections.remove(section_name) + + def clear(self): + """Clear all sections from the explorer.""" + for section in reversed(self._sections): + self._remove_section(section) + def add_script_section(self): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) @@ -84,13 +96,7 @@ class IDEExplorer(BECWidget, QWidget): section.set_widget(script_explorer) self.main_explorer.add_section(section) - plugin_scripts_dir = None - plugins = importlib.metadata.entry_points(group="bec") - for plugin in plugins: - if plugin.name == "plugin_bec": - plugin = plugin.load() - plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts") - break + plugin_scripts_dir = self._get_plugin_dir("scripts") if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): return @@ -102,9 +108,6 @@ class IDEExplorer(BECWidget, QWidget): script_explorer.add_section(shared_script_section) shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) - # macros_section = CollapsibleSection("MACROS", indentation=0) - # macros_section.set_widget(QLabel("Macros will be implemented later")) - # self.main_explorer.add_section(macros_section) def add_macro_section(self): section = CollapsibleSection( @@ -134,13 +137,7 @@ class IDEExplorer(BECWidget, QWidget): section.set_widget(macro_explorer) self.main_explorer.add_section(section) - plugin_macros_dir = None - plugins = importlib.metadata.entry_points(group="bec") - for plugin in plugins: - if plugin.name == "plugin_bec": - plugin = plugin.load() - plugin_macros_dir = os.path.join(plugin.__path__[0], "macros") - break + plugin_macros_dir = self._get_plugin_dir("macros") if not plugin_macros_dir or not os.path.exists(plugin_macros_dir): return @@ -153,6 +150,19 @@ class IDEExplorer(BECWidget, QWidget): shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None: + """Get the path to the specified directory within the BEC plugin. + + Returns: + The path to the specified directory, or None if not found. + """ + plugins = importlib.metadata.entry_points(group="bec") + for plugin in plugins: + if plugin.name == "plugin_bec": + plugin = plugin.load() + return os.path.join(plugin.__path__[0], dir_name) + return None + def _emit_file_open_scripts_local(self, file_name: str): self.file_open_requested.emit(file_name, "scripts/local") @@ -281,7 +291,7 @@ def {function_name}(): Add your macro implementation here. """ - print(f"Executing macro: {function_name}") + print("Executing macro: {function_name}") # TODO: Add your macro code here pass ''' diff --git a/pyproject.toml b/pyproject.toml index 1bb448e9..ec81c252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,13 @@ dependencies = [ "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", "thefuzz~=0.22", - "qtmonaco~=0.7", + "qtmonaco~=0.8, >=0.8.1", "darkdetect~=0.8", "PySide6-QtAds==4.4.0", - "pylsp-bec", + "pylsp-bec~=1.2", "copier~=9.7", "typer~=0.15", + "markdown~=3.9", ] diff --git a/tests/unit_tests/test_collapsible_tree_section.py b/tests/unit_tests/test_collapsible_tree_section.py new file mode 100644 index 00000000..028f5fe0 --- /dev/null +++ b/tests/unit_tests/test_collapsible_tree_section.py @@ -0,0 +1,119 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +from unittest import mock + +import pytest +from qtpy.QtCore import QMimeData, QPoint, Qt +from qtpy.QtWidgets import QLabel + +from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection + + +@pytest.fixture +def collapsible_section(qtbot): + """Create a basic CollapsibleSection widget for testing""" + widget = CollapsibleSection(title="Test Section") + qtbot.addWidget(widget) + yield widget + + +@pytest.fixture +def dummy_content_widget(qtbot): + """Create a simple widget to be used as content""" + widget = QLabel("Test Content") + qtbot.addWidget(widget) + return widget + + +def test_basic_initialization(collapsible_section): + """Test basic initialization""" + assert collapsible_section.title == "Test Section" + assert collapsible_section.expanded is True + assert collapsible_section.content_widget is None + + +def test_toggle_expanded(collapsible_section): + """Test toggling expansion state""" + assert collapsible_section.expanded is True + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is False + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is True + + +def test_set_widget(collapsible_section, dummy_content_widget): + """Test setting content widget""" + collapsible_section.set_widget(dummy_content_widget) + assert collapsible_section.content_widget == dummy_content_widget + assert dummy_content_widget.parent() == collapsible_section + + +def test_connect_add_button(qtbot): + """Test connecting add button""" + widget = CollapsibleSection(title="Test", show_add_button=True) + qtbot.addWidget(widget) + + mock_slot = mock.MagicMock() + widget.connect_add_button(mock_slot) + + qtbot.mouseClick(widget.header_add_button, Qt.MouseButton.LeftButton) + mock_slot.assert_called_once() + + +def test_section_reorder_signal(collapsible_section): + """Test section reorder signal emission""" + signals_received = [] + collapsible_section.section_reorder_requested.connect( + lambda source, target: signals_received.append((source, target)) + ) + + # Create mock drop event + mime_data = QMimeData() + mime_data.setText("section:Source Section") + + mock_event = mock.MagicMock() + mock_event.mimeData.return_value = mime_data + + collapsible_section._header_drop_event(mock_event) + + assert len(signals_received) == 1 + assert signals_received[0] == ("Source Section", "Test Section") + + +def test_nested_collapsible_sections(qtbot): + """Test that collapsible sections can be nested""" + # Create parent section + parent_section = CollapsibleSection(title="Parent Section") + qtbot.addWidget(parent_section) + + # Create child section + child_section = CollapsibleSection(title="Child Section") + qtbot.addWidget(child_section) + + # Add some content to the child section + child_content = QLabel("Child Content") + qtbot.addWidget(child_content) + child_section.set_widget(child_content) + + # Nest the child section inside the parent + parent_section.set_widget(child_section) + + # Verify nesting structure + assert parent_section.content_widget == child_section + assert child_section.parent() == parent_section + assert child_section.content_widget == child_content + assert child_content.parent() == child_section + + # Test that both sections can expand/collapse independently + assert parent_section.expanded is True + assert child_section.expanded is True + + # Collapse child section + child_section.toggle_expanded() + assert child_section.expanded is False + assert parent_section.expanded is True # Parent should remain expanded + + # Collapse parent section + parent_section.toggle_expanded() + assert parent_section.expanded is False + assert child_section.expanded is False # Child state unchanged diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py new file mode 100644 index 00000000..6a4d5d9a --- /dev/null +++ b/tests/unit_tests/test_developer_view.py @@ -0,0 +1,378 @@ +""" +Unit tests for the Developer View widget. + +This module tests the DeveloperView widget functionality including: +- Widget initialization and setup +- Monaco editor integration +- IDE Explorer integration +- File operations (open, save, format) +- Context menu actions +- Toolbar functionality +""" + +import os +import tempfile +from unittest import mock + +import pytest +from qtpy.QtWidgets import QDialog + +from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + +from .client_mocks import mocked_client + + +@pytest.fixture +def developer_view(qtbot, mocked_client): + """Create a DeveloperWidget for testing.""" + widget = DeveloperWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def temp_python_file(): + """Create a temporary Python file for testing.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write( + """# Test Python file +import os +import sys + +def test_function(): + return "Hello, World!" + +if __name__ == "__main__": + print(test_function()) +""" + ) + temp_file_path = f.name + + yield temp_file_path + + # Cleanup + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +@pytest.fixture +def mock_scan_control_dialog(): + """Mock the ScanControlDialog for testing.""" + with mock.patch( + "bec_widgets.widgets.editors.monaco.scan_control_dialog.ScanControlDialog" + ) as mock_dialog: + # Configure the mock dialog + mock_dialog_instance = mock.MagicMock() + mock_dialog_instance.exec_.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.get_scan_code.return_value = ( + "scans.ascan(dev.samx, 0, 1, 10, exp_time=0.1)" + ) + mock_dialog.return_value = mock_dialog_instance + yield mock_dialog_instance + + +class TestDeveloperViewInitialization: + """Test developer view initialization and basic functionality.""" + + def test_developer_view_initialization(self, developer_view): + """Test that the developer view initializes correctly.""" + # Check that main components are created + assert hasattr(developer_view, "monaco") + assert hasattr(developer_view, "explorer") + assert hasattr(developer_view, "console") + assert hasattr(developer_view, "terminal") + assert hasattr(developer_view, "toolbar") + assert hasattr(developer_view, "dock_manager") + assert hasattr(developer_view, "plotting_ads") + assert hasattr(developer_view, "signature_help") + + def test_monaco_editor_integration(self, developer_view): + """Test that Monaco editor is properly integrated.""" + assert isinstance(developer_view.monaco, MonacoDock) + assert developer_view.monaco.parent() is not None + + def test_ide_explorer_integration(self, developer_view): + """Test that IDE Explorer is properly integrated.""" + assert isinstance(developer_view.explorer, IDEExplorer) + assert developer_view.explorer.parent() is not None + + def test_toolbar_components(self, developer_view): + """Test that toolbar components are properly set up.""" + assert developer_view.toolbar is not None + + # Check for expected toolbar actions + toolbar_components = developer_view.toolbar.components + expected_actions = ["save", "save_as", "run", "stop", "vim"] + + for action_name in expected_actions: + assert toolbar_components.exists(action_name) + + def test_dock_manager_setup(self, developer_view): + """Test that dock manager is properly configured.""" + assert developer_view.dock_manager is not None + + # Check that docks are added + dock_widgets = developer_view.dock_manager.dockWidgets() + assert len(dock_widgets) >= 4 # Explorer, Monaco, Console, Terminal + + +class TestFileOperations: + """Test file operation functionality.""" + + def test_open_new_file(self, developer_view, temp_python_file, qtbot): + """Test opening a new file in the Monaco editor.""" + # Simulate opening a file through the IDE explorer signal + developer_view._open_new_file(temp_python_file, "scripts/local") + + # Wait for the file to be loaded + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file was opened + assert temp_python_file in developer_view.monaco._get_open_files() + + # Check that content was loaded (simplified check) + # Get the editor dock for the file and check its content + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + editor_widget = dock.widget() + assert "test_function" in editor_widget.get_text() + + def test_open_shared_file_readonly(self, developer_view, temp_python_file, qtbot): + """Test that shared files are opened in read-only mode.""" + # Open file with shared scope + developer_view._open_new_file(temp_python_file, "scripts/shared") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file is set to read-only + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + monaco_widget = dock.widget() + # Check that the widget is in read-only mode + # This depends on MonacoWidget having a readonly property or method + assert monaco_widget is not None + + def test_file_icon_assignment(self, developer_view, temp_python_file, qtbot): + """Test that file icons are assigned based on scope.""" + # Test script file icon + developer_view._open_new_file(temp_python_file, "scripts/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that an icon was set (simplified check) + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + assert not dock.icon().isNull() + + def test_save_functionality(self, developer_view, qtbot): + """Test the save functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save() + mock_save.assert_called_once() + + def test_save_as_functionality(self, developer_view, qtbot): + """Test the save as functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save as test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save as action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save_as() + mock_save.assert_called_once_with(force_save_as=True) + + +class TestMonacoEditorIntegration: + """Test Monaco editor specific functionality.""" + + def test_vim_mode_toggle(self, developer_view, qtbot): + """Test vim mode toggle functionality.""" + # Test enabling vim mode + with mock.patch.object(developer_view.monaco, "set_vim_mode") as mock_vim: + developer_view.on_vim_triggered() + # The actual call depends on the checkbox state + mock_vim.assert_called_once() + + def test_context_menu_insert_scan(self, developer_view, mock_scan_control_dialog, qtbot): + """Test the Insert Scan context menu action.""" + # This functionality is handled by individual MonacoWidget instances + # Test that the dock has editor widgets + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + assert len(dock_widgets) >= 1 + + # Test on the first available editor + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + assert isinstance(monaco_widget, MonacoWidget) + + def test_context_menu_format_code(self, developer_view, qtbot): + """Test the Format Code context menu action.""" + # Get an editor widget from the dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Set some unformatted Python code + unformatted_code = "import os,sys\ndef test():\n x=1+2\n return x" + monaco_widget.set_text(unformatted_code) + + qtbot.waitUntil(lambda: monaco_widget.get_text() == unformatted_code, timeout=1000) + + # Test format action on the individual widget + with mock.patch.object(monaco_widget, "format") as mock_format: + monaco_widget.format() + mock_format.assert_called_once() + + def test_save_enabled_signal_handling(self, developer_view, qtbot): + """Test that save enabled signals are handled correctly.""" + # Mock the toolbar update method + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate save enabled signal + developer_view.monaco.save_enabled.emit(True) + mock_update.assert_called_with(True) + + developer_view.monaco.save_enabled.emit(False) + mock_update.assert_called_with(False) + + +class TestIDEExplorerIntegration: + """Test IDE Explorer integration.""" + + def test_file_open_signal_connection(self, developer_view): + """Test that file open signals are properly connected.""" + # Test that the signal connection works by mocking the connected method + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the signal to test the connection + developer_view.explorer.file_open_requested.emit("test_file.py", "scripts/local") + mock_open.assert_called_once_with("test_file.py", "scripts/local") + + def test_file_preview_signal_connection(self, developer_view): + """Test that file preview signals are properly connected.""" + # Test that the signal exists and can be emitted (basic connection test) + try: + developer_view.explorer.file_preview_requested.emit("test_file.py", "scripts/local") + # If no exception is raised, the signal exists and is connectable + assert True + except AttributeError: + assert False, "file_preview_requested signal not found" + + def test_sections_configuration(self, developer_view): + """Test that IDE Explorer sections are properly configured.""" + assert "scripts" in developer_view.explorer.sections + assert "macros" in developer_view.explorer.sections + + +class TestToolbarIntegration: + """Test toolbar functionality and integration.""" + + def test_toolbar_save_button_state(self, developer_view): + """Test toolbar save button state management.""" + # Test that save buttons exist and can be controlled + save_action = developer_view.toolbar.components.get_action("save") + save_as_action = developer_view.toolbar.components.get_action("save_as") + + # Test that the actions exist and are accessible + assert save_action.action is not None + assert save_as_action.action is not None + + # Test that they can be enabled/disabled via the update method + developer_view._on_save_enabled_update(False) + assert not save_action.action.isEnabled() + assert not save_as_action.action.isEnabled() + + developer_view._on_save_enabled_update(True) + assert save_action.action.isEnabled() + assert save_as_action.action.isEnabled() + + def test_vim_mode_button_toggle(self, developer_view, qtbot): + """Test vim mode button toggle functionality.""" + vim_action = developer_view.toolbar.components.get_action("vim") + + if vim_action: + # Test toggling vim mode + initial_state = vim_action.action.isChecked() + + # Simulate button click + vim_action.action.trigger() + + # Check that state changed + assert vim_action.action.isChecked() != initial_state + + +class TestErrorHandling: + """Test error handling in various scenarios.""" + + def test_invalid_scope_handling(self, developer_view, temp_python_file): + """Test handling of invalid scope parameters.""" + # Test with invalid scope + try: + developer_view._open_new_file(temp_python_file, "invalid/scope") + except Exception as e: + assert False, f"Invalid scope should be handled gracefully: {e}" + + def test_monaco_editor_error_handling(self, developer_view): + """Test error handling in Monaco editor operations.""" + # Test with editor widgets from dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Test setting invalid text + try: + monaco_widget.set_text(None) # This might cause an error + except Exception: + # Errors should be handled gracefully + pass + + +class TestSignalIntegration: + """Test signal connections and data flow.""" + + def test_file_open_signal_flow(self, developer_view, temp_python_file, qtbot): + """Test the complete file open signal flow.""" + # Mock the _open_new_file method to verify it gets called + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the file open signal from explorer + developer_view.explorer.file_open_requested.emit(temp_python_file, "scripts/local") + + # Verify the signal was handled + mock_open.assert_called_once_with(temp_python_file, "scripts/local") + + def test_save_enabled_signal_flow(self, developer_view, qtbot): + """Test the save enabled signal flow.""" + # Mock the update method (the actual method is _on_save_enabled_update) + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate monaco dock emitting save enabled signal + developer_view.monaco.save_enabled.emit(True) + + # Verify the signal was handled + mock_update.assert_called_once_with(True) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index ba1b9eec..cfdf3d5f 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -1,7 +1,9 @@ import os +from pathlib import Path from unittest import mock import pytest +from qtpy.QtWidgets import QMessageBox from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -34,3 +36,423 @@ def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir): ): 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 + + +@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") as f: + content = f.read() + assert "def test_macro_function():" in content + assert "test_macro_function macro" in content + + +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_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) 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)) + + +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_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(unrelated_file) + + # Should not call refresh_file_item + 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 diff --git a/tests/unit_tests/test_macro_tree_widget.py b/tests/unit_tests/test_macro_tree_widget.py new file mode 100644 index 00000000..501836cb --- /dev/null +++ b/tests/unit_tests/test_macro_tree_widget.py @@ -0,0 +1,548 @@ +""" +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() diff --git a/tests/unit_tests/test_monaco_dock.py b/tests/unit_tests/test_monaco_dock.py new file mode 100644 index 00000000..0a1d6b88 --- /dev/null +++ b/tests/unit_tests/test_monaco_dock.py @@ -0,0 +1,425 @@ +import os +from typing import Generator +from unittest import mock + +import pytest +from qtpy.QtWidgets import QFileDialog, QMessageBox + +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +from .client_mocks import mocked_client + + +@pytest.fixture +def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]: + """Create a MonacoDock for testing.""" + # Mock the macros functionality + mocked_client.macros = mock.MagicMock() + mocked_client.macros._update_handler = mock.MagicMock() + mocked_client.macros._update_handler.get_macros_from_file.return_value = {} + mocked_client.macros._update_handler.get_existing_macros.return_value = {} + + widget = MonacoDock(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestFocusEditor: + def test_last_focused_editor_initial_none(self, monaco_dock: MonacoDock): + """Test that last_focused_editor is initially None.""" + assert monaco_dock.last_focused_editor is not None + + def test_set_last_focused_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test setting last_focused_editor when an editor is focused.""" + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) # Wait for the editor to be fully set up + + assert monaco_dock.last_focused_editor is not None + + def test_last_focused_editor_updates_on_focus_change( + self, qtbot, monaco_dock: MonacoDock, tmpdir + ): + """Test that last_focused_editor updates when focus changes.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + assert editor1 != editor2 + assert editor2 is not None + + def test_opening_existing_file_updates_focus(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that opening an already open file simply switches focus to it.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + # Re-open file1 + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1_again = monaco_dock.last_focused_editor + + assert editor1 == editor1_again + assert editor1 != editor2 + assert editor2 is not None + + +class TestSaveFiles: + def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving an existing file that is not a macro.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Verify the editor is marked as modified + assert editor_widget.modified + + # Save the file + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QFileDialog.getSaveFileName" + ) as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + monaco_dock.save_file() + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + def test_save_file_with_macros_scope(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a file with macros scope updates macro handler.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Mock macro validation to return True (valid) + with mock.patch.object(monaco_dock, "_validate_macros", return_value=True): + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") # User cancels + # Save the file (should save to existing file, not open dialog) + monaco_dock.save_file() + qtbot.wait(100) + + # Verify macro update methods were called + monaco_dock.client.macros._update_handler.get_macros_from_file.assert_called_with( + str(file_path) + ) + monaco_dock.client.macros._update_handler.get_existing_macros.assert_called_with( + str(file_path) + ) + + def test_save_file_invalid_macro_content(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a macro file with invalid content shows warning.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content to invalid macro + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("exec('print(hello)')") # Invalid macro content + qtbot.wait(100) + + # Mock QMessageBox to capture warning + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.warning" + ) as mock_warning: + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify validation was called and warning was shown + mock_warning.assert_called_once() + + # Verify file was not saved (content should remain original) + saved_content = file_path.read() + assert saved_content == "def test_function(): pass" + + def test_save_file_as_new_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As functionality creates a new file.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('New file content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName + new_file_path = str(tmpdir.join("new_file.py")) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (new_file_path, "Python files (*.py)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify new file was created + assert os.path.exists(new_file_path) + with open(new_file_path, "r", encoding="utf-8") as f: + content = f.read() + assert content == 'print("New file content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + # Verify current_file was updated + assert editor_widget.current_file == new_file_path + + def test_save_file_as_adds_py_extension(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As automatically adds .py extension if none provided.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Test content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName to return path without extension + file_path_no_ext = str(tmpdir.join("test_file")) + expected_path = file_path_no_ext + ".py" + + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (file_path_no_ext, "All files (*)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify file was created with .py extension + assert os.path.exists(expected_path) + assert editor_widget.current_file == expected_path + + def test_save_file_no_focused_editor(self, monaco_dock: MonacoDock): + """Test save_file handles case when no editor is focused.""" + # Set last_focused_editor to None + with mock.patch.object(monaco_dock.last_focused_editor, "widget", return_value=None): + # Attempt to save should not raise exception + monaco_dock.save_file() + + def test_save_file_emits_macro_file_updated_signal(self, qtbot, monaco_dock, tmpdir): + """Test that macro_file_updated signal is emitted when saving macro files.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Connect signal to capture emission + signal_emitted = [] + monaco_dock.macro_file_updated.connect(lambda path: signal_emitted.append(path)) + + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify signal was emitted + assert len(signal_emitted) == 1 + assert signal_emitted[0] == str(file_path) + + def test_close_dock_asks_to_save_modified_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that closing a modified file dock asks to save changes.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Mock QMessageBox to simulate user clicking 'Save' + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.StandardButton.Yes + + # Mock QFileDialog.getSaveFileName + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + + # Close the dock; sadly, calling close() alone does not trigger the closeRequested signal + # It is only triggered if the mouse is on top of the tab close button, so we directly call the handler + monaco_dock._on_editor_close_requested( + monaco_dock.last_focused_editor, editor_widget + ) + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + +class TestSignatureHelp: + def test_signature_help_signal_emission(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help signal is emitted correctly.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data + signature_data = { + "signatures": [ + { + "label": "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)", + "documentation": { + "value": "Print objects to the text stream file, separated by sep and followed by end." + }, + } + ], + "activeSignature": 0, + "activeParameter": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct markdown format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)" in emitted_signature + assert "Print objects to the text stream file" in emitted_signature + + def test_signature_help_empty_signatures(self, qtbot, monaco_dock: MonacoDock): + """Test signature help with empty signatures.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with no signatures + signature_data = {"signatures": []} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify empty string was emitted + assert len(signature_emitted) == 1 + assert signature_emitted[0] == "" + + def test_signature_help_no_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is missing.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data without documentation + signature_data = {"signatures": [{"label": "function_name(param)"}], "activeSignature": 0} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with just the function signature + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + + def test_signature_help_string_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is a string instead of dict.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with string documentation + signature_data = { + "signatures": [ + {"label": "function_name(param)", "documentation": "Simple string documentation"} + ], + "activeSignature": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + assert "Simple string documentation" in emitted_signature + + def test_signature_help_connected_to_editor(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help is connected when creating new editors.""" + # Create a new editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + + # Verify the signal connection exists by checking connected signals + # We do this by mocking the signal and verifying the connection + with mock.patch.object(monaco_dock, "_on_signature_change") as mock_handler: + # Simulate signature help trigger from the editor + editor_widget.editor.signature_help_triggered.emit({"signatures": []}) + qtbot.wait(100) + + # Verify the handler was called + mock_handler.assert_called_once() diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py index 149f4d75..3f1c24fc 100644 --- a/tests/unit_tests/test_monaco_editor.py +++ b/tests/unit_tests/test_monaco_editor.py @@ -1,11 +1,20 @@ -import pytest +from unittest import mock +import pytest +from bec_lib.endpoints import MessageEndpoints + +from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +from .client_mocks import mocked_client +from .test_scan_control import available_scans_message @pytest.fixture -def monaco_widget(qtbot): - widget = MonacoWidget() +def monaco_widget(qtbot, mocked_client): + widget = MonacoWidget(client=mocked_client) + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -37,3 +46,75 @@ def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot): monaco_widget.set_text("Attempting to change text") qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000) assert monaco_widget.get_text() == "Attempting to change text" + + +def test_monaco_widget_show_scan_control_dialog(monaco_widget: MonacoWidget, qtbot): + """ + Test that the MonacoWidget can show the scan control dialog. + """ + + with mock.patch.object(monaco_widget, "_run_dialog_and_insert_code") as mock_run_dialog: + monaco_widget._show_scan_control_dialog() + mock_run_dialog.assert_called_once() + + +def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, mocked_client): + """ + Test that the MonacoWidget can get scan control code from the dialog. + """ + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) + + scan_control_dialog = ScanControlDialog(client=mocked_client) + qtbot.addWidget(scan_control_dialog) + qtbot.waitExposed(scan_control_dialog) + qtbot.wait(300) + + scan_control = scan_control_dialog.scan_control + scan_name = "grid_scan" + kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2} + args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20} + args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10} + mock_slot = mock.MagicMock() + + scan_control.scan_args.connect(mock_slot) + + scan_control.comboBox_scan_selection.setCurrentText(scan_name) + + # Ensure there are two rows in the arg_box + current_rows = scan_control.arg_box.count_arg_rows() + required_rows = 2 + while current_rows < required_rows: + scan_control.arg_box.add_widget_bundle() + current_rows += 1 + + # Set kwargs in the UI + for kwarg_box in scan_control.kwarg_boxes: + for widget in kwarg_box.widgets: + if widget.arg_name in kwargs: + WidgetIO.set_value(widget, kwargs[widget.arg_name]) + + # Set args in the UI for both rows + arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets + num_columns = len(scan_control.arg_box.inputs) + num_rows = int(len(arg_widgets) / num_columns) + assert num_rows == required_rows # We expect 2 rows for grid_scan + + # Set values for first row + for i in range(num_columns): + widget = arg_widgets[i] + arg_name = widget.arg_name + if arg_name in args_row1: + WidgetIO.set_value(widget, args_row1[arg_name]) + + # Set values for second row + for i in range(num_columns): + widget = arg_widgets[num_columns + i] # Next row + arg_name = widget.arg_name + if arg_name in args_row2: + WidgetIO.set_value(widget, args_row2[arg_name]) + + scan_control_dialog.accept() + out = scan_control_dialog.get_scan_code() + + expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None)" + assert out == expected_code diff --git a/tests/unit_tests/test_tree_widget.py b/tests/unit_tests/test_tree_widget.py deleted file mode 100644 index 7a470cce..00000000 --- a/tests/unit_tests/test_tree_widget.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -from qtpy.QtWidgets import QTreeView, QWidget - -from bec_widgets.utils.colors import apply_theme - - -class DummyTree(QWidget): - def __init__(self): - super().__init__() - tree = QTreeView(self) - - -@pytest.fixture -def tree_widget(qtbot): - tree = DummyTree() - qtbot.addWidget(tree) - qtbot.waitExposed(tree) - yield tree - - -def test_tree_widget_init(tree_widget): - assert isinstance(tree_widget, QWidget)