import webbrowser from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from qtpy.QtCore import QEvent, QPoint, QPointF from qtpy.QtGui import QEnterEvent from qtpy.QtWidgets import QApplication, QFrame, QLabel from bec_widgets.widgets.containers.main_window.addons.hover_widget import ( HoverWidget, WidgetTooltip, ) 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.containers.main_window.main_window import BECMainWindow from .client_mocks import mocked_client from .conftest import create_widget @pytest.fixture def bec_main_window(qtbot, mocked_client): widget = BECMainWindow(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget ################################################################# # Tests for BECMainWindow Initialization and Functionality ################################################################# def test_bec_main_window_initialization(bec_main_window): assert isinstance(bec_main_window, BECMainWindow) assert bec_main_window.windowTitle() == "BEC" assert bec_main_window.app is not None assert bec_main_window.statusBar() is not None assert bec_main_window._app_id_label is not None def test_bec_main_window_display_client_message(qtbot, bec_main_window): """ Verify that display_client_message updates the client‑info label. """ test_msg = "Client connected successfully" bec_main_window.display_client_message({"message": test_msg}, {}) qtbot.wait(200) assert bec_main_window._client_info_label.text() == test_msg def test_status_bar_has_separator(bec_main_window): """Ensure the status bar contains at least one vertical separator.""" status_bar = bec_main_window.statusBar() separators = [w for w in status_bar.findChildren(QFrame) if w.frameShape() == QFrame.VLine] assert separators, "Expected at least one QFrame separator in the status bar." def test_display_app_id_not_connected(bec_main_window): with patch.object(bec_main_window.bec_dispatcher, "cli_server", None): bec_main_window.display_app_id() assert bec_main_window._app_id_label.text() == "Not connected" def test_display_app_id_connected(bec_main_window): with patch.object(bec_main_window.bec_dispatcher, "cli_server", MagicMock(gui_id="gui_123")): bec_main_window.display_app_id() assert bec_main_window._app_id_label.text() == "App ID: gui_123" def test_event_consumes_status_tip(bec_main_window): status_tip_event = QEvent(QEvent.Type.StatusTip) assert bec_main_window.event(status_tip_event) is True def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window): with patch.object( QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: []) ): assert bec_main_window._get_launcher_from_qapp() is None def test_show_launcher_warns_when_cli_server_missing(bec_main_window): with ( patch.object(bec_main_window.bec_dispatcher, "cli_server", None), patch.object(bec_main_window, "_get_launcher_from_qapp", return_value=None), patch("bec_widgets.widgets.containers.main_window.main_window.logger.warning") as mock_warn, ): bec_main_window._show_launcher() mock_warn.assert_called_once() def test_show_launcher_creates_launcher_when_missing(bec_main_window): launcher = MagicMock() with ( patch.object(bec_main_window.bec_dispatcher, "cli_server", MagicMock(gui_id="server_id")), patch.object(bec_main_window, "_get_launcher_from_qapp", return_value=None), patch("bec_widgets.applications.launch_window.LaunchWindow", return_value=launcher) as cls, ): bec_main_window._show_launcher() cls.assert_called_once_with(gui_id="server_id:launcher") launcher.setAttribute.assert_called_once() launcher.show.assert_called_once() launcher.activateWindow.assert_called_once() launcher.raise_.assert_called_once() assert bec_main_window._launcher_window is launcher def test_hidden_scan_progress_parent_blocks_children_namespace(bec_main_window): hidden_progress = bec_main_window._scan_progress_bar_full nested_progress = hidden_progress.progressbar assert hidden_progress.rpc_exposed is False assert nested_progress.parent_id == hidden_progress.gui_id ################################################################# # Tests for BECMainWindow Addons ################################################################# ################################################################# # Tests for ScrollLabel behaviour def test_scroll_label_does_not_scroll_when_text_fits(qtbot): """Label with short text should not activate scrolling timer.""" lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed qtbot.addWidget(lbl) lbl.resize(200, 20) lbl.setText("Short text") # Process events to allow timer logic to run qtbot.wait(200) assert not lbl._timer.isActive() assert not lbl._delay_timer.isActive() def test_scroll_label_starts_scrolling(qtbot): """Label with long text should start _delay_timer; later _timer becomes active.""" lbl = create_widget(qtbot, ScrollLabel, delay_ms=100) lbl.resize(150, 20) long_text = "This is a very long piece of text that should definitely overflow the label width" lbl.setText(long_text) # Immediately after setText, only delay‑timer should be active assert lbl._delay_timer.isActive() assert not lbl._timer.isActive() # Wait until scrolling timer becomes active qtbot.waitUntil(lambda: lbl._timer.isActive(), timeout=2000) assert lbl._timer.isActive() def test_scroll_label_scroll_method(qtbot): """Directly exercise _scroll to ensure offset advances and paintEvent is invoked.""" lbl = create_widget(qtbot, ScrollLabel, step_px=5) # shorten delay for test speed qtbot.addWidget(lbl) lbl.resize(120, 20) lbl.setText("x" * 200) # long text to guarantee overflow qtbot.wait(200) # let timers configure themselves # Capture current offset and force a manual scroll tick old_offset = lbl._offset lbl._scroll() assert lbl._offset == old_offset + 5 def test_scroll_label_paint_event(qtbot): """ Grab the widget as a pixmap; this calls paintEvent under the hood and ensures no exceptions occur during rendering. """ lbl = create_widget(qtbot, ScrollLabel) # shorten delay for test speed qtbot.addWidget(lbl) lbl.resize(180, 20) lbl.setText("Rendering check") lbl.show() qtbot.wait(200) # allow Qt to schedule a paint pixmap = lbl.grab() assert not pixmap.isNull() def test_display_client_message_with_expiration(qtbot, bec_main_window): """ A message with a finite 'expire' value should disappear once the timer fires. """ test_msg = "This message should vanish fast" expire_sec = 0.2 bec_main_window.display_client_message({"message": test_msg, "expire": expire_sec}, {}) assert bec_main_window._client_info_expire_timer.isActive() assert bec_main_window._client_info_label.text() == test_msg qtbot.waitUntil(lambda: not bec_main_window._client_info_expire_timer.isActive(), timeout=1000) assert bec_main_window._client_info_label.text() == "" def test_display_client_message_no_expiration(qtbot, bec_main_window): """ A message with 'expire' == 0 must persist and never start the timer. """ test_msg = "Persistent status message" bec_main_window.display_client_message({"message": test_msg, "expire": 0}, {}) assert not bec_main_window._client_info_expire_timer.isActive() assert bec_main_window._client_info_label.text() == test_msg qtbot.wait(500) assert bec_main_window._client_info_label.text() == test_msg def test_display_client_message_overwrite_resets_timer(qtbot, bec_main_window): """ Sending a second message while the expiration timer is active should overwrite the first and stop the timer if the second one is persistent. """ first_msg = "First (temporary)" second_msg = "Second (persistent)" bec_main_window.display_client_message({"message": first_msg, "expire": 0.3}, {}) qtbot.wait(200) assert bec_main_window._client_info_expire_timer.isActive() bec_main_window.display_client_message({"message": second_msg, "expire": 0}, {}) assert not bec_main_window._client_info_expire_timer.isActive() assert bec_main_window._client_info_label.text() == second_msg qtbot.wait(400) assert bec_main_window._client_info_label.text() == second_msg ################################################################# # Tests for BECWebLinksMixin (webbrowser opening) def test_bec_weblinks(monkeypatch): opened_urls = [] def fake_open(url): opened_urls.append(url) monkeypatch.setattr(webbrowser, "open", fake_open) BECWebLinksMixin.open_bec_docs() BECWebLinksMixin.open_bec_widgets_docs() BECWebLinksMixin.open_bec_bug_report() assert opened_urls == [ "https://beamline-experiment-control.readthedocs.io/en/latest/", "https://bec.readthedocs.io/projects/bec-widgets/en/latest/", "https://github.com/bec-project/bec_widgets/issues", ] ################################################################# # Tests for hover widget and tooltip behaviour def test_hover_widget_tooltip(qtbot): """ After a HoverWidget is closed, its WidgetTooltip must be gone. """ simple = QLabel("Hover me") full = QLabel("Full details") hover = create_widget(qtbot, HoverWidget, simple=simple, full=full) assert hover._simple is simple assert hover._full is full assert hover._tooltip is None def test_widget_tooltip_show_and_hide(qtbot): """ WidgetTooltip should appear when show_above is called and hide on Leave. """ full_lbl = QLabel("Standalone tooltip content") tooltip = create_widget(qtbot, WidgetTooltip, content=full_lbl) # Show above an arbitrary point pos = QPoint(200, 200) tooltip.show_above(pos) assert tooltip.isVisible() # Send a synthetic Leave event QApplication.sendEvent(tooltip, QEvent(QEvent.Leave)) qtbot.waitUntil(lambda: not tooltip.isVisible(), timeout=500) assert not tooltip.isVisible() def test_hover_widget_mouse_events(qtbot): """ Verify that HoverWidget responds correctly to Enter, MouseMove, and Leave events, keeping the tooltip visible only while the pointer is inside. """ simple = QLabel("Hover‑target") full = QLabel("Full‑view") hover = create_widget(qtbot, HoverWidget, simple=simple, full=full) local = QPointF(hover.rect().center()) # inside widget scene = QPointF(hover.mapTo(hover.window(), local.toPoint())) global_ = QPointF(hover.mapToGlobal(local.toPoint())) enter_event = QEnterEvent(local, scene, global_) hover.enterEvent(event=enter_event) qtbot.wait(200) assert hover._tooltip is not None assert hover._tooltip.isVisible() assert hover._tooltip.content is full