1
0
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:
2025-10-08 17:42:41 +02:00
committed by Klaus Wakonig
parent da1a3ddfc4
commit 0d0eb1d8ee
16 changed files with 2335 additions and 363 deletions

View File

@@ -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.

View File

@@ -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")

View 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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
'''

View File

@@ -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",
]

View 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

View 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__])

View 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

View 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()

View 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()

View File

@@ -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

View File

@@ -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)