mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-12-27 09:31:18 +01:00
341 lines
10 KiB
Python
341 lines
10 KiB
Python
import pytest
|
||
from qtpy import QtCore, QtGui, QtWidgets
|
||
|
||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
|
||
DARK_PALETTE,
|
||
LIGHT_PALETTE,
|
||
SEVERITY,
|
||
BECNotificationBroker,
|
||
NotificationCentre,
|
||
NotificationIndicator,
|
||
NotificationToast,
|
||
SeverityKind,
|
||
)
|
||
|
||
from .client_mocks import mocked_client
|
||
|
||
|
||
@pytest.fixture
|
||
def toast(qtbot):
|
||
"""Return a NotificationToast with a very short lifetime (50 ms) for fast tests."""
|
||
t = NotificationToast(
|
||
title="Test Title", body="Test Body", kind=SeverityKind.WARNING, lifetime_ms=50 # 0.05 s
|
||
)
|
||
qtbot.addWidget(t)
|
||
qtbot.waitExposed(t)
|
||
return t
|
||
|
||
|
||
def test_initial_state(toast):
|
||
"""Constructor should correctly propagate title / body / kind."""
|
||
assert toast.title == "Test Title"
|
||
assert toast.body == "Test Body"
|
||
assert toast.kind == SeverityKind.WARNING
|
||
# progress bar height fixed at 4 px
|
||
assert toast.progress.maximumHeight() == 4
|
||
|
||
|
||
def test_apply_theme_updates_colours(qtbot, toast):
|
||
"""apply_theme("light") should inject LIGHT palette colours into stylesheets."""
|
||
toast.apply_theme("light")
|
||
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
|
||
|
||
toast.apply_theme("dark")
|
||
assert DARK_PALETTE["title"] in toast._title_lbl.styleSheet()
|
||
|
||
|
||
def test_expired_signal(qtbot, toast):
|
||
"""Toast must emit expired once its lifetime finishes."""
|
||
with qtbot.waitSignal(toast.expired, timeout=1000):
|
||
pass
|
||
assert toast._expired
|
||
|
||
|
||
def test_closed_signal(qtbot, toast):
|
||
"""Calling close() must emit closed."""
|
||
with qtbot.waitSignal(toast.closed, timeout=1000):
|
||
toast.close()
|
||
|
||
|
||
def test_property_setters_update_ui(qtbot, toast):
|
||
"""Changing properties through setters should update both state and label text."""
|
||
|
||
# title
|
||
toast.title = "New Title"
|
||
assert toast.title == "New Title"
|
||
assert toast._title_lbl.text() == "New Title"
|
||
|
||
# body
|
||
toast.body = "New Body"
|
||
assert toast.body == "New Body"
|
||
assert toast._body_lbl.text() == "New Body"
|
||
|
||
# kind
|
||
toast.kind = SeverityKind.MINOR
|
||
assert toast.kind == SeverityKind.MINOR
|
||
expected_color = SEVERITY["minor"]["color"]
|
||
assert toast._accent_color.name() == QtGui.QColor(expected_color).name()
|
||
|
||
# traceback
|
||
new_tb = "Traceback: divide by zero"
|
||
toast.traceback = new_tb
|
||
assert toast.traceback == new_tb
|
||
assert toast.trace_view.toPlainText() == new_tb
|
||
|
||
|
||
def _make_enter_event(widget):
|
||
"""Utility: synthetic QEnterEvent centred on *widget*."""
|
||
centre = widget.rect().center()
|
||
local = QtCore.QPointF(centre)
|
||
scene = QtCore.QPointF(widget.mapTo(widget.window(), centre))
|
||
global_ = QtCore.QPointF(widget.mapToGlobal(centre))
|
||
return QtGui.QEnterEvent(local, scene, global_)
|
||
|
||
|
||
def test_time_label_toggle_absolute(qtbot, toast):
|
||
"""Hovering time-label switches between relative and absolute timestamp."""
|
||
rel_text = toast.time_lbl.text()
|
||
|
||
# Enter
|
||
QtWidgets.QApplication.sendEvent(toast.time_lbl, _make_enter_event(toast.time_lbl))
|
||
qtbot.wait(100)
|
||
abs_text = toast.time_lbl.text()
|
||
assert abs_text != rel_text and "-" in abs_text and ":" in abs_text
|
||
|
||
# Leave
|
||
QtWidgets.QApplication.sendEvent(toast.time_lbl, QtCore.QEvent(QtCore.QEvent.Leave))
|
||
qtbot.wait(100)
|
||
assert toast.time_lbl.text() != abs_text
|
||
|
||
|
||
def test_hover_pauses_and_resumes_expiry(qtbot):
|
||
"""Countdown must pause on hover and resume on leave."""
|
||
t = NotificationToast(title="Hover", body="x", kind=SeverityKind.INFO, lifetime_ms=200)
|
||
qtbot.addWidget(t)
|
||
qtbot.waitExposed(t)
|
||
|
||
qtbot.wait(50) # allow animation to begin
|
||
# Pause
|
||
QtWidgets.QApplication.sendEvent(t, _make_enter_event(t))
|
||
qtbot.wait(250) # longer than lifetime, but hover keeps it alive
|
||
assert not t._expired
|
||
|
||
# Resume
|
||
QtWidgets.QApplication.sendEvent(t, QtCore.QEvent(QtCore.QEvent.Leave))
|
||
with qtbot.waitSignal(t.expired, timeout=500):
|
||
pass
|
||
assert t._expired
|
||
|
||
|
||
def test_toast_paint_event(qtbot):
|
||
"""
|
||
Grabbing the widget as a pixmap forces paintEvent to execute.
|
||
The test passes if no exceptions occur and the resulting pixmap is valid.
|
||
"""
|
||
t = NotificationToast(title="Paint", body="Check", kind=SeverityKind.INFO, lifetime_ms=0)
|
||
qtbot.addWidget(t)
|
||
t.resize(420, 160)
|
||
t.show()
|
||
qtbot.waitExposed(t)
|
||
|
||
pix = t.grab()
|
||
assert not pix.isNull()
|
||
|
||
|
||
# ------------------------------------------------------------------------
|
||
# NotificationCentre tests
|
||
# ------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def centre(qtbot, mocked_client):
|
||
"""NotificationCentre embedded in a live parent widget kept alive for the test."""
|
||
parent = QtWidgets.QWidget()
|
||
parent.resize(600, 400)
|
||
|
||
ctr = NotificationCentre(parent=parent, fixed_width=300, margin=8)
|
||
broker = BECNotificationBroker(client=mocked_client)
|
||
|
||
layout = QtWidgets.QVBoxLayout(parent)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.addWidget(ctr)
|
||
|
||
# Keep a Python reference so GC doesn't drop the parent (and cascade‑delete centre)
|
||
ctr._test_parent_ref = parent # type: ignore[attr-defined]
|
||
|
||
qtbot.addWidget(parent)
|
||
qtbot.addWidget(ctr)
|
||
parent.show()
|
||
qtbot.waitExposed(parent)
|
||
yield ctr
|
||
broker.reset_singleton()
|
||
|
||
|
||
def _post(ctr: NotificationCentre, kind=SeverityKind.INFO, title="T", body="B"):
|
||
"""Convenience wrapper that posts a toast and returns it."""
|
||
return ctr.add_notification(title=title, body=body, kind=kind, lifetime_ms=0)
|
||
|
||
|
||
# ------------------------------------------------------------------------
|
||
# Tests
|
||
# ------------------------------------------------------------------------
|
||
|
||
|
||
def test_add_notification_emits_signal(qtbot, centre):
|
||
"""Adding a toast emits toast_added and makes centre visible."""
|
||
with qtbot.waitSignal(centre.toast_added, timeout=500) as sig:
|
||
toast = _post(centre, SeverityKind.INFO)
|
||
|
||
assert toast in centre.toasts
|
||
assert sig.args == [SeverityKind.INFO.value]
|
||
|
||
|
||
def test_counts_updated(qtbot, centre):
|
||
"""counts_updated reflects current per-kind counts."""
|
||
seen = []
|
||
|
||
centre.counts_updated.connect(lambda d: seen.append(d.copy()))
|
||
_post(centre, SeverityKind.INFO)
|
||
_post(centre, SeverityKind.WARNING)
|
||
qtbot.wait(100)
|
||
|
||
assert seen[-1][SeverityKind.INFO] == 1
|
||
assert seen[-1][SeverityKind.WARNING] == 1
|
||
|
||
centre.clear_all()
|
||
qtbot.wait(100)
|
||
assert seen[-1][SeverityKind.INFO] == 0
|
||
assert seen[-1][SeverityKind.WARNING] == 0
|
||
|
||
|
||
def test_filtering_hides_unrelated_toasts(centre):
|
||
info = _post(centre, SeverityKind.INFO)
|
||
warn = _post(centre, SeverityKind.WARNING)
|
||
|
||
centre.apply_filter({SeverityKind.INFO})
|
||
assert info.isVisible()
|
||
assert not warn.isVisible()
|
||
|
||
centre.apply_filter(None)
|
||
assert warn.isVisible()
|
||
|
||
|
||
def test_hide_show_all(qtbot, centre):
|
||
_post(centre, SeverityKind.MINOR)
|
||
|
||
centre.hide_all()
|
||
assert not centre.isVisible()
|
||
|
||
centre.show_all()
|
||
assert centre.isVisible()
|
||
assert all(t.isVisible() for t in centre.toasts)
|
||
|
||
|
||
def test_clear_all(qtbot, centre):
|
||
_post(centre, SeverityKind.INFO)
|
||
_post(centre, SeverityKind.WARNING)
|
||
|
||
# expect two toast_removed emissions
|
||
for _ in range(2):
|
||
qtbot.waitSignal(centre.toast_removed, timeout=500, raising=False)
|
||
|
||
centre.clear_all()
|
||
assert not centre.toasts
|
||
assert not centre.isVisible()
|
||
|
||
|
||
def test_theme_propagation(qtbot, centre):
|
||
toast = _post(centre, SeverityKind.INFO)
|
||
centre.apply_theme("light")
|
||
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
|
||
|
||
|
||
# ------------------------------------------------------------------------
|
||
# NotificationIndicator tests
|
||
# ------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def indicator(qtbot, centre):
|
||
"""Indicator wired to the same centre used in centre fixture."""
|
||
ind = NotificationIndicator()
|
||
qtbot.addWidget(ind)
|
||
|
||
# wire signals
|
||
centre.counts_updated.connect(ind.update_counts)
|
||
ind.filter_changed.connect(centre.apply_filter)
|
||
ind.show_all_requested.connect(centre.show_all)
|
||
ind.hide_all_requested.connect(centre.hide_all)
|
||
|
||
return ind
|
||
|
||
|
||
def _emit_counts(centre: NotificationCentre, info=0, warn=0, minor=0, major=0):
|
||
"""Helper to create dummy toasts and update counts."""
|
||
for _ in range(info):
|
||
_post(centre, SeverityKind.INFO)
|
||
for _ in range(warn):
|
||
_post(centre, SeverityKind.WARNING)
|
||
for _ in range(minor):
|
||
_post(centre, SeverityKind.MINOR)
|
||
for _ in range(major):
|
||
_post(centre, SeverityKind.MAJOR)
|
||
|
||
|
||
def test_indicator_updates_visibility(qtbot, centre, indicator):
|
||
"""Indicator shows/hides buttons based on counts."""
|
||
_emit_counts(centre, info=1)
|
||
qtbot.wait(50)
|
||
|
||
# "info" button visible, others hidden
|
||
assert indicator._btn[SeverityKind.INFO].isVisible()
|
||
assert not indicator._btn[SeverityKind.WARNING].isVisible()
|
||
|
||
# add warning toast → warning button appears
|
||
_emit_counts(centre, warn=1)
|
||
qtbot.wait(50)
|
||
assert indicator._btn[SeverityKind.WARNING].isVisible()
|
||
|
||
# clear all → indicator hides itself
|
||
centre.clear_all()
|
||
qtbot.wait(50)
|
||
assert not indicator.isVisible()
|
||
|
||
|
||
def test_indicator_filter_buttons(qtbot, centre, indicator):
|
||
"""Toggling buttons emits appropriate filter signals."""
|
||
# add two kinds so indicator is visible
|
||
_emit_counts(centre, info=1, warn=1)
|
||
qtbot.wait(200)
|
||
|
||
# click INFO button
|
||
with qtbot.waitSignal(indicator.filter_changed, timeout=500) as sig:
|
||
qtbot.mouseClick(indicator._btn[SeverityKind.INFO], QtCore.Qt.LeftButton)
|
||
assert sig.args[0] == {SeverityKind.INFO}
|
||
|
||
|
||
def test_broker_posts_notification(qtbot, centre, mocked_client):
|
||
"""post_notification should create a toast in the centre with correct data."""
|
||
broker = BECNotificationBroker(parent=None, client=mocked_client, centre=centre)
|
||
broker._err_util = ErrorPopupUtility()
|
||
|
||
msg = {
|
||
"alarm_type": "ValueError",
|
||
"msg": "test alarm",
|
||
"severity": 2, # MAJOR
|
||
"source": {"device": "samx", "source": "async_file_writer"},
|
||
}
|
||
|
||
broker.post_notification(msg, meta={})
|
||
qtbot.wait(200) # allow toast to be posted
|
||
|
||
# One toast should now exist
|
||
assert len(centre.toasts) == 1
|
||
toast = centre.toasts[0]
|
||
|
||
assert toast.title == "ValueError"
|
||
assert "Error occurred. See details." in toast.body
|
||
assert toast.kind == SeverityKind.MAJOR
|
||
assert toast._lifetime == 0
|