Compare commits

...

7 Commits

17 changed files with 836 additions and 29 deletions
@@ -189,6 +189,7 @@ class LaunchTile(RoundedFrame):
class LaunchWindow(BECMainWindow):
RPC = True
PLUGIN = False
TILE_SIZE = (250, 300)
DEFAULT_LAUNCH_SIZE = (800, 600)
USER_ACCESS = ["show_launcher", "hide_launcher"]
+9 -4
View File
@@ -5,6 +5,7 @@ from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.screen_utils import (
@@ -12,11 +13,12 @@ from bec_widgets.utils.screen_utils import (
available_screen_geometry,
main_app_size_for_screen,
)
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class BECMainApp(BECMainWindow):
RPC = False
PLUGIN = False
def __init__(
self,
@@ -50,13 +52,16 @@ class BECMainApp(BECMainWindow):
def _add_views(self):
self.add_section("BEC Applications", "bec_apps")
self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
self.ads.setObjectName("MainWorkspace")
self.dock_area = DockAreaView(self)
self.device_manager = DeviceManagerView(self)
self.developer_view = DeveloperView(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
icon="widgets",
title="Dock Area",
id="dock_area",
widget=self.dock_area,
mini_text="Docks",
)
self.add_view(
icon="display_settings",
@@ -79,6 +79,8 @@ def markdown_to_html(md_text: str) -> str:
class DeveloperWidget(DockAreaWidget):
RPC = False
PLUGIN = False
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, variant="compact", **kwargs)
@@ -0,0 +1,24 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
class DockAreaView(ViewBase):
"""
Modular dock area view for arranging and managing multiple dockable widgets.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
id: str | None = None,
title: str | None = None,
):
super().__init__(parent=parent, content=content, id=id, title=title)
self.dock_area = BECDockArea(
self, profile_namespace="bec", auto_profile_namespace=False, object_name="DockArea"
)
self.set_content(self.dock_area)
+14
View File
@@ -2848,6 +2848,20 @@ class ImageItem(RPCBase):
"""
class LaunchWindow(RPCBase):
@rpc_call
def show_launcher(self):
"""
Show the launcher window.
"""
@rpc_call
def hide_launcher(self):
"""
Hide the launcher window.
"""
class LogPanel(RPCBase):
"""Displays a log panel"""
+2 -1
View File
@@ -291,7 +291,8 @@ def main():
client_path = module_dir / client_subdir / "client.py"
rpc_classes = get_custom_classes(module_name)
packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",)
rpc_classes = get_custom_classes(module_name, packages=packages)
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
generator = ClientGenerator(base=module_name == "bec_widgets")
+2 -1
View File
@@ -32,7 +32,8 @@ class RPCWidgetHandler:
None
"""
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
+ get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:
+3 -2
View File
@@ -12,7 +12,7 @@ import shiboken6 as shb
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -23,7 +23,6 @@ from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, s
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.dock import BECDock
else:
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -273,6 +272,8 @@ class BECConnector:
Args:
name (str): The new object name.
"""
# sanitize before setting to avoid issues with Qt object names and RPC namespaces
name = sanitize_namespace(name)
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):
+91
View File
@@ -0,0 +1,91 @@
"""
Login dialog for user authentication.
The Login Widget is styled in a Material Design style and emits
the entered credentials through a signal for further processing.
"""
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
class BECLogin(QWidget):
"""Login dialog for user authentication in Material Design style."""
credentials_entered = Signal(str, str)
def __init__(self, parent=None):
super().__init__(parent=parent)
# Only displayed if this widget as standalone widget, and not embedded in another widget
self.setWindowTitle("Login")
title = QLabel("Sign in", parent=self)
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet(
"""
#QLabel
{
font-size: 18px;
font-weight: 600;
}
"""
)
self.username = QLineEdit(parent=self)
self.username.setPlaceholderText("Username")
self.password = QLineEdit(parent=self)
self.password.setPlaceholderText("Password")
self.password.setEchoMode(QLineEdit.EchoMode.Password)
self.ok_btn = QPushButton("Sign in", parent=self)
self.ok_btn.setDefault(True)
self.ok_btn.clicked.connect(self._emit_credentials)
# If the user presses Enter in the password field, trigger the OK button click
self.password.returnPressed.connect(self.ok_btn.click)
# Build Layout
layout = QVBoxLayout(self)
layout.setContentsMargins(32, 32, 32, 32)
layout.setSpacing(16)
layout.addWidget(title)
layout.addSpacing(8)
layout.addWidget(self.username)
layout.addWidget(self.password)
layout.addSpacing(12)
layout.addWidget(self.ok_btn)
self.username.setFocus()
self.setStyleSheet(
"""
QLineEdit {
padding: 8px;
}
"""
)
def _clear_password(self):
"""Clear the password field."""
self.password.clear()
def _emit_credentials(self):
"""Emit credentials and clear the password field."""
self.credentials_entered.emit(self.username.text().strip(), self.password.text())
self._clear_password()
if __name__ == "__main__": # pragma: no cover
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("light")
dialog = BECLogin()
dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}"))
dialog.show()
sys.exit(app.exec_())
+38 -21
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
@@ -166,18 +166,17 @@ class BECClassContainer:
return [info.obj for info in self.collection]
def get_custom_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
collection = BECClassContainer()
anchor_module = importlib.import_module(f"{repo_name}.widgets")
try:
anchor_module = importlib.import_module(f"{repo_name}.{package}")
except ModuleNotFoundError as exc:
# Some plugin repositories expose only one subtree. Skip gracefully if it does not exist.
if exc.name == f"{repo_name}.{package}":
return collection
raise
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
@@ -185,13 +184,13 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
rel_dir = os.path.dirname(os.path.relpath(path, directory))
if rel_dir in ("", "."):
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
module = importlib.import_module(f"{repo_name}.{package}.{module_name}")
for name in dir(module):
obj = getattr(module, name)
@@ -203,12 +202,30 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
class_info.is_top_level = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
return collection
def get_custom_classes(
repo_name: str, packages: tuple[str, ...] | None = None
) -> BECClassContainer:
"""
Get all relevant classes for RPC/CLI in the specified repository.
By default, discovery is limited to ``<repo>.widgets`` for backward compatibility.
Additional package subtrees (for example ``applications``) can be included explicitly.
Args:
repo_name(str): The name of the repository.
packages(tuple[str, ...] | None): Optional tuple of package names to scan. Defaults to ("widgets",) for backward compatibility.
Returns:
BECClassContainer: Container with collected class information.
"""
selected_packages = packages or ("widgets",)
collection = BECClassContainer()
for package in selected_packages:
collection += _collect_classes_from_package(repo_name, package)
return collection
@@ -3,6 +3,7 @@ from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
@@ -31,6 +32,7 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -342,6 +344,34 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
# Add separator before feedback
help_menu.addSeparator()
# Feedback action
feedback_icon = QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_MessageBoxQuestion
)
feedback_action = QAction("Feedback", self)
feedback_action.setIcon(feedback_icon)
feedback_action.triggered.connect(self._show_feedback_dialog)
help_menu.addAction(feedback_action)
def _show_feedback_dialog(self):
"""Show the feedback dialog and handle the submitted feedback."""
dialog = FeedbackDialog(self)
def on_feedback_submitted(rating: int, comment: str, email: str):
rating = max(1, min(rating, 5)) # Ensure rating is between 1 and 5
username = os.getlogin()
message = messages.FeedbackMessage(
feedback=comment, rating=rating, contact=email, username=username
)
self.bec_dispatcher.client.connector.send(MessageEndpoints.submit_feedback(), message)
dialog.feedback_submitted.connect(on_feedback_submitted)
dialog.exec()
################################################################################
# Status Bar Addons
################################################################################
@@ -0,0 +1,294 @@
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
class StarRating(QWidget):
"""
A star rating widget that allows users to rate from 1 to 5 stars.
"""
rating_changed = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._rating = 0
self._hovered_star = 0
self._star_buttons = []
# Get theme colors
theme = getattr(QApplication.instance(), "theme", None)
if theme:
SafeConnect(self, theme.theme_changed, self._update_theme_colors)
self._update_theme_colors()
# Enable mouse tracking to handle hover across the entire widget
self.setMouseTracking(True)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
for i in range(5):
btn = QPushButton("")
btn.setFixedSize(30, 30)
btn.setFlat(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.clicked.connect(lambda checked=False, idx=i + 1: self._set_rating(idx))
layout.addWidget(btn)
self._star_buttons.append(btn)
self.setLayout(layout)
self._update_display()
@SafeSlot(str)
def _update_theme_colors(self, _theme: str | None = None):
"""Update colors based on theme."""
theme = getattr(QApplication.instance(), "theme", None)
colors = theme.colors if theme else {}
self._inactive_color = colors.get("SEPARATOR", QColor(200, 200, 200))
self._active_color = colors.get("ACCENT_WARNING", QColor(255, 193, 7))
# Update display if already initialized
if hasattr(self, "_star_buttons") and self._star_buttons:
self._update_display()
def _set_rating(self, rating: int):
"""Set the rating and emit the signal."""
if self._rating != rating:
self._rating = rating
self.rating_changed.emit(rating)
self._update_display()
def mouseMoveEvent(self, event):
"""Handle mouse movement to update hovered star."""
# Calculate which star is being hovered based on mouse position
x_pos = event.pos().x()
star_idx = 0
# Find which star region we're in (including gaps between stars)
for i, btn in enumerate(self._star_buttons):
btn_geometry = btn.geometry()
# If we're to the right of this button's left edge, this is the current star
# (including the gap before the next button)
if x_pos >= btn_geometry.left():
star_idx = i + 1
else:
break
if star_idx != self._hovered_star:
self._hovered_star = star_idx
self._update_display()
super().mouseMoveEvent(event)
def leaveEvent(self, event):
"""Handle mouse leaving the widget."""
self._hovered_star = 0
self._update_display()
super().leaveEvent(event)
def _update_display(self):
"""Update the visual display of stars."""
display_rating = self._hovered_star if self._hovered_star > 0 else self._rating
inactive_color_name = self._inactive_color.name()
active_color_name = self._active_color.name()
for i, btn in enumerate(self._star_buttons):
if i < display_rating:
btn.setStyleSheet(
f"""
QPushButton {{
border: none;
background: transparent;
font-size: 24px;
color: {active_color_name};
}}
"""
)
else:
btn.setStyleSheet(
f"""
QPushButton {{
border: none;
background: transparent;
font-size: 24px;
color: {inactive_color_name};
}}
QPushButton:hover {{
color: {active_color_name};
}}
"""
)
def rating(self) -> int:
"""Get the current rating."""
return self._rating
def set_rating(self, rating: int):
"""Set the rating programmatically."""
if 0 <= rating <= 5:
self._set_rating(rating)
class FeedbackDialog(QDialog):
"""
A feedback dialog widget containing a comment field, star rating, and optional email field.
Signals:
feedbackSubmitted: Emitted when feedback is submitted (rating: int, comment: str, email: str)
"""
feedback_submitted = Signal(int, str, str)
ICON_NAME = "feedback"
PLUGIN = True
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Feedback")
self.setModal(True)
self.setMinimumWidth(400)
self.setMinimumHeight(300)
self._setup_ui()
def _setup_ui(self):
"""Set up the user interface."""
layout = QVBoxLayout()
layout.setSpacing(15)
# Title
title_label = QLabel("We'd love to hear your feedback!")
title_font = QFont()
title_font.setPointSize(12)
title_font.setBold(True)
title_label.setFont(title_font)
layout.addWidget(title_label)
# Star rating section
rating_layout = QVBoxLayout()
rating_label = QLabel("Rating:")
rating_layout.addWidget(rating_label)
self._star_rating = StarRating()
rating_layout.addWidget(self._star_rating)
layout.addLayout(rating_layout)
# Comment section
comment_label = QLabel("Comments:")
layout.addWidget(comment_label)
self._comment_field = QTextEdit()
self._comment_field.setPlaceholderText("Please share your thoughts...")
self._comment_field.setMaximumHeight(150)
layout.addWidget(self._comment_field)
# Email section (optional)
email_label = QLabel("Email (optional, for follow-up):")
layout.addWidget(email_label)
self._email_field = QLineEdit()
self._email_field.setPlaceholderText("your.email@example.com")
layout.addWidget(self._email_field)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
self._cancel_button = QPushButton("Cancel")
self._cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self._cancel_button)
self._submit_button = QPushButton("Submit")
self._submit_button.setDefault(True)
self._submit_button.clicked.connect(self._on_submit)
button_layout.addWidget(self._submit_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def _on_submit(self):
"""Handle submit button click."""
rating = self._star_rating.rating()
comment = self._comment_field.toPlainText().strip()
email = self._email_field.text().strip()
# Emit the feedback signal
self.feedback_submitted.emit(rating, comment, email)
# Accept the dialog
self.accept()
def get_feedback(self) -> tuple[int, str, str]:
"""
Get the current feedback values.
Returns:
tuple: (rating, comment, email)
"""
return (
self._star_rating.rating(),
self._comment_field.toPlainText().strip(),
self._email_field.text().strip(),
)
def set_rating(self, rating: int):
"""Set the star rating."""
self._star_rating.set_rating(rating)
def set_comment(self, comment: str):
"""Set the comment text."""
self._comment_field.setPlainText(comment)
def set_email(self, email: str):
"""Set the email text."""
self._email_field.setText(email)
@staticmethod
def show_feedback_dialog(parent=None) -> tuple[int, str, str] | None:
"""
Show the feedback dialog and return the feedback if submitted.
Args:
parent: Parent widget
Returns:
tuple: (rating, comment, email) if submitted, None if cancelled
"""
dialog = FeedbackDialog(parent)
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_feedback()
return None
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
apply_theme("dark")
dialog = FeedbackDialog()
def on_feedback(rating, comment, email):
print(f"Rating: {rating}")
print(f"Comment: {comment}")
print(f"Email: {email}")
dialog.feedback_submitted.connect(on_feedback)
dialog.exec()
sys.exit(app.exec())
+12
View File
@@ -39,6 +39,18 @@ def test_bec_connector_set_gui_id(bec_connector):
assert bec_connector.config.gui_id == "test_gui_id"
def test_bec_connector_sanitize_names(mocked_client):
class MyWidget(BECConnector, QWidget):
def __init__(self, parent=None, client=None, **kwargs):
super().__init__(parent=parent, client=client, **kwargs)
widget = MyWidget(client=mocked_client)
widget.setObjectName("Test Name With Spaces")
assert widget.objectName() == "Test_Name_With_Spaces"
widget.setObjectName("Test@Name#With$Special%Characters!")
assert widget.objectName() == "Test_Name_With_Special_Characters_"
def test_bec_connector_change_config(bec_connector):
bec_connector.on_config_update({"gui_id": "test_gui_id"})
assert bec_connector.config.gui_id == "test_gui_id"
+262
View File
@@ -0,0 +1,262 @@
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QDialog
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog, StarRating
@pytest.fixture
def star_rating(qtbot):
"""Create a StarRating widget for testing."""
widget = StarRating()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def feedback_dialog(qtbot):
"""Create a FeedbackDialog for testing."""
dialog = FeedbackDialog()
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
yield dialog
dialog.close()
class TestStarRating:
"""Tests for the StarRating widget."""
def test_initial_state(self, star_rating):
"""Test that StarRating initializes with rating 0."""
assert star_rating.rating() == 0
assert star_rating._hovered_star == 0
assert len(star_rating._star_buttons) == 5
def test_set_rating_via_method(self, star_rating):
"""Test setting rating programmatically."""
star_rating.set_rating(3)
assert star_rating.rating() == 3
star_rating.set_rating(5)
assert star_rating.rating() == 5
def test_set_rating_bounds(self, star_rating):
"""Test that rating is bounded between 0 and 5."""
star_rating.set_rating(0)
assert star_rating.rating() == 0
star_rating.set_rating(5)
assert star_rating.rating() == 5
# Out of bounds should not change rating
initial_rating = star_rating.rating()
star_rating.set_rating(6)
assert star_rating.rating() == initial_rating
star_rating.set_rating(-1)
assert star_rating.rating() == initial_rating
def test_rating_signal_emission(self, star_rating, qtbot):
"""Test that rating_changed signal is emitted when rating changes."""
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000) as blocker:
star_rating.set_rating(4)
assert blocker.args == [4]
def test_rating_signal_not_emitted_on_same_value(self, star_rating, qtbot):
"""Test that signal is not emitted when setting the same rating."""
star_rating.set_rating(3)
# Should not emit signal when setting same value
with qtbot.assertNotEmitted(star_rating.rating_changed, wait=100):
star_rating.set_rating(3)
def test_click_star_button(self, star_rating, qtbot):
"""Test clicking on star buttons."""
# Click the third star (index 2)
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000):
qtbot.mouseClick(star_rating._star_buttons[2], Qt.LeftButton)
assert star_rating.rating() == 3
# Click the first star
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000):
qtbot.mouseClick(star_rating._star_buttons[0], Qt.LeftButton)
assert star_rating.rating() == 1
def test_mouse_hover(self, star_rating, qtbot):
"""Test mouse hover behavior."""
# Set initial rating
star_rating.set_rating(2)
assert star_rating._hovered_star == 0
# Simulate mouse move over the fourth button
btn = star_rating._star_buttons[3]
btn_center = btn.geometry().center()
event = qtbot.mouseMove(star_rating, pos=btn_center)
# Note: _hovered_star should be updated by mouseMoveEvent
# This is a bit tricky to test directly, so we verify the method exists
assert hasattr(star_rating, "mouseMoveEvent")
assert hasattr(star_rating, "leaveEvent")
def test_leave_event(self, star_rating, qtbot):
"""Test that leaving the widget clears hover state."""
star_rating.set_rating(2)
star_rating._hovered_star = 4 # Simulate hover
# Trigger leave event
star_rating.leaveEvent(None)
assert star_rating._hovered_star == 0
assert star_rating.rating() == 2 # Rating should remain unchanged
def test_update_theme_colors(self, star_rating):
"""Test that theme colors are applied correctly."""
assert hasattr(star_rating, "_inactive_color")
assert hasattr(star_rating, "_active_color")
# Colors should be initialized
assert star_rating._inactive_color is not None
assert star_rating._active_color is not None
def test_display_update(self, star_rating):
"""Test that display updates when rating changes."""
star_rating.set_rating(3)
# If this doesn't raise an exception, the display was updated successfully
star_rating._update_display()
class TestFeedbackDialog:
"""Tests for the FeedbackDialog widget."""
def test_initial_state(self, feedback_dialog):
"""Test that FeedbackDialog initializes correctly."""
assert feedback_dialog.windowTitle() == "Feedback"
assert feedback_dialog.isModal() is True
assert feedback_dialog._star_rating is not None
assert feedback_dialog._comment_field is not None
assert feedback_dialog._email_field is not None
assert feedback_dialog._submit_button is not None
assert feedback_dialog._cancel_button is not None
def test_get_feedback_initial(self, feedback_dialog):
"""Test getting feedback from unmodified dialog."""
rating, comment, email = feedback_dialog.get_feedback()
assert rating == 0
assert comment == ""
assert email == ""
def test_set_and_get_rating(self, feedback_dialog):
"""Test setting and getting rating."""
feedback_dialog.set_rating(4)
rating, _, _ = feedback_dialog.get_feedback()
assert rating == 4
def test_set_and_get_comment(self, feedback_dialog):
"""Test setting and getting comment."""
test_comment = "This is a test comment"
feedback_dialog.set_comment(test_comment)
_, comment, _ = feedback_dialog.get_feedback()
assert comment == test_comment
def test_set_and_get_email(self, feedback_dialog):
"""Test setting and getting email."""
test_email = "test@example.com"
feedback_dialog.set_email(test_email)
_, _, email = feedback_dialog.get_feedback()
assert email == test_email
def test_set_all_feedback(self, feedback_dialog):
"""Test setting all feedback fields."""
feedback_dialog.set_rating(5)
feedback_dialog.set_comment("Great widget!")
feedback_dialog.set_email("user@example.com")
rating, comment, email = feedback_dialog.get_feedback()
assert rating == 5
assert comment == "Great widget!"
assert email == "user@example.com"
def test_submit_button_emits_signal(self, feedback_dialog, qtbot):
"""Test that clicking submit emits feedback_submitted signal."""
feedback_dialog.set_rating(3)
feedback_dialog.set_comment("Test feedback")
feedback_dialog.set_email("test@test.com")
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
assert blocker.args == [3, "Test feedback", "test@test.com"]
def test_submit_button_accepts_dialog(self, feedback_dialog, qtbot):
"""Test that clicking submit accepts the dialog."""
feedback_dialog.set_rating(4)
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
qtbot.wait(100)
# Dialog should be accepted
assert feedback_dialog.result() == QDialog.DialogCode.Accepted
def test_cancel_button_rejects_dialog(self, feedback_dialog, qtbot):
"""Test that clicking cancel rejects the dialog."""
qtbot.mouseClick(feedback_dialog._cancel_button, Qt.LeftButton)
qtbot.wait(100)
# Dialog should be rejected
assert feedback_dialog.result() == QDialog.DialogCode.Rejected
def test_submit_with_empty_fields(self, feedback_dialog, qtbot):
"""Test submitting with empty fields."""
# Don't set any values
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
# Should emit with empty values
assert blocker.args == [0, "", ""]
def test_submit_strips_whitespace(self, feedback_dialog, qtbot):
"""Test that whitespace is stripped from comment and email."""
feedback_dialog.set_comment(" Test comment ")
feedback_dialog.set_email(" test@example.com ")
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
rating, comment, email = blocker.args
assert comment == "Test comment"
assert email == "test@example.com"
def test_dialog_has_correct_properties(self, feedback_dialog):
"""Test that dialog has correct class properties."""
assert hasattr(FeedbackDialog, "ICON_NAME")
assert FeedbackDialog.ICON_NAME == "feedback"
assert hasattr(FeedbackDialog, "PLUGIN")
assert FeedbackDialog.PLUGIN is True
def test_comment_field_placeholder(self, feedback_dialog):
"""Test that comment field has placeholder text."""
assert feedback_dialog._comment_field.placeholderText() != ""
def test_email_field_placeholder(self, feedback_dialog):
"""Test that email field has placeholder text."""
assert feedback_dialog._email_field.placeholderText() != ""
def test_submit_button_is_default(self, feedback_dialog):
"""Test that submit button is set as default."""
assert feedback_dialog._submit_button.isDefault() is True
def test_star_rating_embedded_correctly(self, feedback_dialog, qtbot):
"""Test that StarRating widget is properly embedded."""
# Verify we can interact with the embedded star rating
feedback_dialog._star_rating.set_rating(5)
assert feedback_dialog._star_rating.rating() == 5
# Verify rating is reflected in feedback
rating, _, _ = feedback_dialog.get_feedback()
assert rating == 5
+52
View File
@@ -0,0 +1,52 @@
"""Test the BEC Login widget"""
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QLineEdit
from bec_widgets.utils.bec_login import BECLogin
@pytest.fixture
def login_dialog(qtbot):
"""Fixture to create a BECLogin instance."""
dialog = BECLogin()
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog) # Ensure the dialog is fully shown before running tests
return dialog
def test_utils_login_dialog_initialization(login_dialog, qtbot):
"""Test that the BECLogin initializes correctly."""
assert login_dialog.windowTitle() == "Login"
assert login_dialog.username.placeholderText() == "Username"
assert login_dialog.password.placeholderText() == "Password"
assert login_dialog.password.echoMode() == QLineEdit.EchoMode.Password
assert login_dialog.ok_btn.text() == "Sign in"
# Initially, this should be empty
with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker:
qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton)
assert blocker.args == ["", ""]
def test_utils_login_dialog_emit_credentials(login_dialog, qtbot):
"""Test that the BECLogin emits credentials correctly."""
test_username = "testuser "
test_password = "testpass"
login_dialog.username.setText(test_username)
login_dialog.password.setText(test_password)
with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker:
qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton)
assert blocker.args == [test_username.strip(), test_password]
assert login_dialog.password.text() == "" # Password should be cleared after emitting
login_dialog.password.setText(test_password)
with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker:
qtbot.keyClick(login_dialog.password, Qt.Key.Key_Return)
assert blocker.args == [test_username.strip(), test_password]
assert login_dialog.password.text() == "" # Password should be cleared after emitting