mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-04 16:02:51 +01:00
fix: developer view improvements and dependency updates
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
125
bec_widgets/widgets/containers/explorer/explorer_delegate.py
Normal file
125
bec_widgets/widgets/containers/explorer/explorer_delegate.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
'''
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
119
tests/unit_tests/test_collapsible_tree_section.py
Normal file
119
tests/unit_tests/test_collapsible_tree_section.py
Normal file
@@ -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
|
||||
378
tests/unit_tests/test_developer_view.py
Normal file
378
tests/unit_tests/test_developer_view.py
Normal file
@@ -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__])
|
||||
@@ -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
|
||||
|
||||
548
tests/unit_tests/test_macro_tree_widget.py
Normal file
548
tests/unit_tests/test_macro_tree_widget.py
Normal file
@@ -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()
|
||||
425
tests/unit_tests/test_monaco_dock.py
Normal file
425
tests/unit_tests/test_monaco_dock.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user