Files
AareDAQ/tests/unit/gui/test_status_bar.py
T
duan_jandClaude Fable 5 8bd7e30e0b feat(gui): pin status-bar operables right, stop readout width jitter
- Fast Shutter / State / p-group / Session are addPermanentWidget now:
  QStatusBar itself pins them to the right corner, so wrapping of the
  other readouts never moves them
- passive readouts keep wrapping in the FlowHost on narrow windows
- grow-only min-width ratchet on every readout: changing number widths
  used to re-flow the row each DAQ tick and jitter every neighbour

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-17 14:27:19 +02:00

115 lines
4.9 KiB
Python

"""Status bar layout contract: every readout lives in one wrapping flow
host (narrow windows wrap instead of clipping), and every operable label
wears the shared hover affordance."""
from aarecommon.models.models import TokenData
from PySide6.QtCore import QEvent, QPointF, Qt
from PySide6.QtGui import QEnterEvent
from aare.gui.widgets.flow_layout import FlowLayout
from aare.gui.widgets.status_bar import StatusBar
def _bar(qtbot):
bar = StatusBar(token=TokenData(sub="u", staff=True, pgroups=["p1"], session=1))
qtbot.addWidget(bar)
bar.show()
return bar
def test_operables_share_the_hover_affordance(qtbot):
bar = _bar(qtbot)
for label in (bar.shutter_label, bar.state_label, bar.pgroup_label, bar.session_label):
assert label.cursor().shape() == Qt.CursorShape.PointingHandCursor
# Cryo is grouped with the operables but stays passive for now (TODO in
# status_bar.py); the plain readouts are passive too.
for label in (bar.cryo_label, bar.flux, bar.busy_label):
assert label.cursor().shape() != Qt.CursorShape.PointingHandCursor
# Hover underlines, leaving restores.
enter = QEnterEvent(QPointF(1, 1), QPointF(1, 1), QPointF(1, 1))
bar.state_label.enterEvent(enter)
assert bar.state_label.font().underline()
bar.state_label.leaveEvent(QEvent(QEvent.Type.Leave))
assert not bar.state_label.font().underline()
def test_state_menu_gates_beam_location_for_non_staff(qtbot, daq_status_factory, monkeypatch):
from aarecommon.models.models import BeamlineStateEnum
from PySide6.QtWidgets import QMenu
import aare.gui.widgets.status_bar as status_bar_module
# exec() would block on a real popup, and PySide's method lookup ignores
# a class-attribute monkeypatch — swap in a subclass instead. The menu
# stays inspectable as a child of the bar afterwards.
class _NoExecMenu(QMenu):
def exec(self): # pyright: ignore[reportIncompatibleMethodOverride]
return None
monkeypatch.setattr(status_bar_module, "QMenu", _NoExecMenu)
def _state_menu_entries(bar):
bar._status = daq_status_factory(state=BeamlineStateEnum.SampleAlignment)
bar.show_state_menu()
# Plain data, not QAction refs: the menu (and its actions) only
# lives until the next GC pass once show_state_menu returns.
return {a.text(): a.isEnabled() for a in bar.findChildren(QMenu)[-1].actions()}
assert _state_menu_entries(_bar(qtbot))["Beam location"]
non_staff = StatusBar(token=TokenData(sub="u", staff=False, pgroups=["p1"], session=1))
qtbot.addWidget(non_staff)
assert not _state_menu_entries(non_staff)["Beam location (admin mode only)"]
def test_readout_width_ratchets_so_numbers_do_not_jitter(qtbot, daq_status_factory):
bar = _bar(qtbot)
# A wide value pins the minimum width; a narrower one must NOT shrink it,
# otherwise every tick re-flows the row and neighbours jitter.
bar.update_samcam_fps(1234.5)
bar.update_sharpness(0.123)
wide = bar.samcam_fps.minimumWidth()
assert wide >= bar.samcam_fps.sizeHint().width()
bar.update_samcam_fps(5.0)
assert bar.samcam_fps.minimumWidth() == wide
# The DAQ tick ratchets the rest, session-display path included.
bar.update_daq_status(daq_status_factory())
bar._update_session_display()
assert bar.ring_current.minimumWidth() >= bar.ring_current.sizeHint().width()
assert bar.session_label.minimumWidth() >= bar.session_label.sizeHint().width()
def test_readouts_wrap_instead_of_clipping(qtbot, daq_status_factory):
bar = _bar(qtbot)
# Labels are empty until a DAQ tick paints them; feed one so the flow
# measures realistic widths (empty labels never trigger a wrap).
bar.update_daq_status(daq_status_factory())
host = bar.info_host
flow = host.layout()
assert isinstance(flow, FlowLayout) # narrows Optional for the checker
# Passive readouts wrap in the flow host; the four operables are
# addPermanentWidget so QStatusBar pins them to the right corner and
# they never move when the flow wraps.
for label in (bar.flux, bar.busy_label, bar.cryo_label):
assert label.parentWidget() is host
for label in (bar.shutter_label, bar.state_label, bar.pgroup_label, bar.session_label):
assert label.parentWidget() is bar
assert label.parentWidget() is not host
# Narrow width -> the flow reports a taller (multi-row) height than one
# row, and resizing the host pins its minimum height to the wrapped
# height so the bar grows instead of cutting labels off.
one_row = flow.heightForWidth(100_000)
wrapped = flow.heightForWidth(200)
assert wrapped > one_row
host.resize(200, wrapped)
assert host.minimumHeight() >= wrapped
# Layout bookkeeping the canonical example demands.
assert flow.count() > 0
assert flow.itemAt(0) is not None
assert flow.itemAt(9999) is None
taken = flow.takeAt(flow.count() - 1)
assert taken is not None
assert flow.takeAt(9999) is None