1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-12-27 09:31:18 +01:00
Files
bec_widgets/tests/unit_tests/test_notifications.py

341 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 cascadedelete 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