Files
bec_widgets/tests/unit_tests/test_system_dock.py
T

306 lines
11 KiB
Python

from types import SimpleNamespace
import pytest
from bec_lib import messages
from qtpy.QtCore import QEvent, QPointF, QSettings, Qt
from qtpy.QtGui import QKeyEvent, QMouseEvent
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
from bec_widgets.applications.main_app import BECMainApp
from bec_widgets.applications.system_dock.system_dock import (
MAX_PINNED,
RailIndicator,
SystemDock,
SystemPanel,
)
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.quick.host import ThemeTokens
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import (
NotificationHub,
)
@pytest.fixture
def settings(tmp_path):
return QSettings(str(tmp_path / "dock.ini"), QSettings.Format.IniFormat)
@pytest.fixture
def window(qtbot, settings):
"""A plain window with a content area and the dock on its right edge."""
win = QWidget()
win.resize(1200, 700)
layout = QHBoxLayout(win)
layout.setContentsMargins(0, 0, 0, 0)
win.content = QLabel("content", win)
layout.addWidget(win.content, 1)
win.dock = SystemDock(win, settings=settings)
layout.addWidget(win.dock.pinned_column)
layout.addWidget(win.dock)
for name in ("alpha", "beta", "gamma"):
win.dock.add_panel(
SystemPanel(name, name.title(), "info", name.title(), QLabel(name)),
shortcut="Ctrl+Shift+A" if name == "alpha" else None,
)
win.dock.add_separator()
win.dock.add_panel(
SystemPanel("small", "Small", "info", "Small", QLabel("small"), preferred_height=80)
)
qtbot.addWidget(win)
win.show()
qtbot.waitExposed(win)
yield win
win.dock.cleanup()
def _press(widget: QWidget):
"""Send a mouse press at the centre of ``widget`` through its window, like a real click."""
handle = widget.window().windowHandle()
local = QPointF(widget.mapTo(widget.window(), widget.rect().center()))
global_pos = QPointF(widget.mapToGlobal(widget.rect().center()))
event = QMouseEvent(
QEvent.Type.MouseButtonPress,
local,
global_pos,
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
QApplication.sendEvent(handle, event)
def test_rail_lists_panels_in_order(window):
assert window.dock.panel_ids == ["alpha", "beta", "gamma", "small"]
assert window.dock.peek_panel_id is None
assert not window.dock.hover_open # click to open is the default
def test_click_opens_flyout_and_click_again_closes(qtbot, window):
dock = window.dock
qtbot.mouseClick(dock.button("alpha"), Qt.MouseButton.LeftButton)
assert dock.peek_panel_id == "alpha"
assert dock.peek_sticky
assert dock.peek.isVisible()
assert dock.button("alpha").active
qtbot.mouseClick(dock.button("beta"), Qt.MouseButton.LeftButton)
assert dock.peek_panel_id == "beta"
assert not dock.button("alpha").active
qtbot.mouseClick(dock.button("beta"), Qt.MouseButton.LeftButton)
assert dock.peek_panel_id is None
assert not dock.peek.isVisible()
def test_click_outside_and_escape_close_the_flyout(qtbot, window):
dock = window.dock
dock.open_panel("alpha")
_press(dock.frame("alpha").panel.content)
assert dock.peek_panel_id == "alpha" # clicks inside the flyout keep it open
_press(window.content)
assert dock.peek_panel_id is None
dock.open_panel("alpha")
# real key presses reach the window first, which is where the dock listens
escape = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)
QApplication.sendEvent(window.windowHandle(), escape)
assert dock.peek_panel_id is None
def test_flyout_is_left_of_the_rail_and_compact_panels_are_short(qtbot, window):
dock = window.dock
dock.open_panel("alpha")
qtbot.wait(200)
assert dock.peek.geometry().right() < dock.geometry().left()
assert dock.peek.height() == dock.height()
dock.open_panel("small")
assert dock.peek.height() < dock.height()
def test_pin_moves_panel_into_the_column(qtbot, window, settings):
dock = window.dock
dock.open_panel("alpha")
dock.set_pinned("alpha", True)
assert dock.pinned == ["alpha"]
assert dock.peek_panel_id is None
assert dock.pinned_column.isVisible()
assert dock.frame("alpha").parent() is dock.pinned_column
assert dock.is_shown("alpha")
assert dock.button("alpha").active
assert settings.value("system_dock/pinned") in (["alpha"], "alpha")
# a pinned panel's rail button does not open a flyout
qtbot.mouseClick(dock.button("alpha"), Qt.MouseButton.LeftButton)
assert dock.peek_panel_id is None
dock.close_panel("alpha")
assert dock.pinned == []
assert not dock.pinned_column.isVisible()
assert not dock.is_shown("alpha")
def test_at_most_two_panels_are_pinned(window):
dock = window.dock
for panel_id in ("alpha", "beta", "gamma"):
dock.set_pinned(panel_id, True)
assert len(dock.pinned) == MAX_PINNED
assert dock.pinned == ["beta", "gamma"]
def test_pin_shortcut_pins_the_open_flyout_then_unpins(window):
dock = window.dock
dock.open_panel("beta")
dock.toggle_pin_current()
assert dock.pinned == ["beta"]
dock.toggle_pin_current()
assert dock.pinned == []
def test_panel_shortcut_toggles_the_panel(qtbot, window):
qtbot.keyClick(
window,
Qt.Key.Key_A,
Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier,
)
assert window.dock.peek_panel_id == "alpha"
assert "Ctrl+Shift+A" in window.dock.button("alpha").toolTip()
def test_pinned_panels_are_restored(qtbot, window, settings):
window.dock.set_pinned("gamma", True)
other = QWidget()
qtbot.addWidget(other)
dock = SystemDock(other, settings=settings)
dock.add_panel(SystemPanel("gamma", "Gamma", "info", "Gamma", QLabel("g")))
dock.restore_state()
assert dock.pinned == ["gamma"]
dock.cleanup()
def test_hover_opens_only_when_enabled(qtbot, window):
dock = window.dock
dock._on_button_hovered("alpha", True)
qtbot.wait(400)
assert dock.peek_panel_id is None
dock.set_hover_open(True)
dock.open_panel("alpha", sticky=False)
dock._on_button_hovered("beta", True) # a hover flyout follows the pointer
assert dock.peek_panel_id == "beta"
assert not dock.peek_sticky
def test_indicator_updates_tooltip_and_pulse(window):
dock = window.dock
panel = dock.panel("beta")
panel.set_indicator(RailIndicator(badge="2", tooltip="2 things", pulse=True))
assert "2 things" in dock.button("beta").toolTip()
assert dock._pulse.state() == dock._pulse.State.Running
panel.set_indicator(RailIndicator())
assert dock._pulse.state() != dock._pulse.State.Running
def test_hiding_the_dock_closes_the_flyout(window):
dock = window.dock
dock.open_panel("alpha")
dock.set_pinned("beta", True)
dock.set_dock_visible(False)
assert dock.peek_panel_id is None
assert not dock.isVisible()
assert not dock.pinned_column.isVisible()
dock.set_dock_visible(True)
assert dock.pinned_column.isVisible()
@pytest.fixture
def main_app(qtbot, mocked_client, monkeypatch):
monkeypatch.delenv("BEC_SYSTEM_DOCK", raising=False)
monkeypatch.delenv("BEC_NOTIFICATION_UI", raising=False)
app = BECMainApp(client=mocked_client, anim_duration=10, system_dock="qwidget")
qtbot.addWidget(app)
app.show()
qtbot.waitExposed(app)
yield app
# the reworked notification hub is an app-wide singleton
NotificationHub.reset_instance()
def test_main_app_builds_the_core_panels(main_app):
dock = main_app.system_dock
assert dock.panel_ids == [
"progress",
"queue",
"services",
"beamline_states",
"notifications",
"devices",
]
assert main_app.content_splitter.widget(1) is dock.pinned_column
# the rail replaces the status bar progress bar and bell
assert not main_app._scan_progress_bar_with_separator.isVisible()
assert not main_app.notifications.bell.isVisible()
assert main_app.notification_indicator is dock.button("notifications")
def test_main_app_notifications_open_in_the_dock(qtbot, main_app):
dock = main_app.system_dock
host = main_app.notifications
entry = host.hub.notify("Detector timeout", "eiger9m", "error")
qtbot.waitUntil(lambda: host.hub.unread_count() == 1)
qtbot.waitUntil(lambda: dock.panel("notifications").indicator.badge == "1")
# the toast's Details action opens the dock panel at that entry
host.open_details(entry)
assert dock.peek_panel_id == "notifications"
assert host.drawer_open
assert host.drawer.parent() is not main_app
qtbot.waitUntil(lambda: dock.panel("notifications").indicator.badge == "")
dock.close_peek()
assert not host.drawer_open
def test_main_app_badges_follow_the_widgets(main_app):
dock = main_app.system_dock
tokens = ThemeTokens()
queue = dock.panel("queue")
paused = messages.ScanQueueStatusMessage(
metadata={}, queue={"primary": {"info": [], "status": "PAUSED", "locks": []}}
)
queue.queue.update_queue(paused.content, {})
assert queue.indicator.badge == ""
assert queue.indicator.dot_color == tokens.warning
assert queue.indicator.summary.startswith("Paused")
services = dock.panel("services")
assert services.indicator.dot_color is None or services.box.model.tone in (
"warning",
"emergency",
)
states = dock.panel("beamline_states")
real = states.states
try:
controller = SimpleNamespace(
blockingStates=["shutter_open"],
counts={"invalid": 1, "warning": 0, "unknown": 0, "valid": 3},
totalCount=4,
interlockArmed=True,
)
states.states = SimpleNamespace(controller=controller)
states.refresh()
assert states.indicator.badge == "!"
assert states.indicator.pulse
controller.blockingStates = []
states.refresh()
assert states.indicator.badge == ""
assert states.indicator.dot_color == tokens.warning
controller.counts = {"invalid": 0, "warning": 0, "unknown": 0, "valid": 4}
states.refresh()
assert states.indicator.dot_color is None
assert states.indicator.summary == "All 4 valid · interlock armed"
finally:
states.states = real
def test_main_app_without_dock(qtbot, mocked_client, monkeypatch):
monkeypatch.setenv("BEC_SYSTEM_DOCK", "off")
monkeypatch.delenv("BEC_NOTIFICATION_UI", raising=False)
app = BECMainApp(client=mocked_client, anim_duration=10)
qtbot.addWidget(app)
assert app.system_dock is None
assert app.notifications is None
assert app.stack.parent() is app.centralWidget()