- 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>
115 lines
4.9 KiB
Python
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
|