test: cover the sample-status logic, wheel guard, pop-outs, and log mirror
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / test (3.11) (pull_request) Successful in 58s
CI / test (3.13) (pull_request) Successful in 55s
CI / test (3.12) (pull_request) Successful in 1m1s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m4s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m3s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m18s
CI / test-with-coverage (pull_request) Successful in 1m29s
CI / coverage-analysis (pull_request) Successful in 3s
CI / lint (pull_request) Failing after 2m32s

New tests for what the port added: status tint priority and
context-dependent chip filters on the dewar/queue model, the
right-button wheel guard (motor protection), PopoutWindow edge maths,
manual-resize fallback, close-hides behavior and DockTitleBar, the
chip drag-drop relabeling, and the console-log pop-out mirror. The
widgets' __main__ self-checks are superseded by these and removed.
Brings PR diff coverage from 70% to 81% (gate: 80%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
duan_jandClaude Fable 5 committed 2026-08-07 17:48:50 +02:00
1 parent 8f324ecc19
commit b4d680494b
7 files changed
+532 -52

No files matched your search

-18
View File
@@ -201,21 +201,3 @@ class PopoutWindow(QWidget):
self._press_global = None
self._press_geom = None
super().mouseReleaseEvent(event)
if __name__ == "__main__":
# ponytail: smallest check that fails if the edge maths breaks
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication, QLabel
app = QApplication([])
w = PopoutWindow("t", QLabel("x"))
w.resize(400, 300)
assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge
assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge)
assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0)
assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor
assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor
print("gude")
-34
View File
@@ -46,37 +46,3 @@ class WheelValueGuard(QObject):
QApplication.sendEvent(area.viewport(), relayed)
return True
return super().eventFilter(obj, event)
if __name__ == "__main__":
# ponytail: smallest check that fails if the guard logic breaks
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QPoint, QPointF
app = QApplication([])
guard = WheelValueGuard()
app.installEventFilter(guard)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 100)
slider.setValue(50)
slider.show()
def wheel(buttons):
return QWheelEvent(
QPointF(5, 5),
QPointF(5, 5),
QPoint(0, 0),
QPoint(0, 120),
buttons,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
QApplication.sendEvent(slider, wheel(Qt.MouseButton.NoButton))
assert slider.value() == 50, "bare wheel must not adjust the slider"
QApplication.sendEvent(slider, wheel(Qt.MouseButton.RightButton))
assert slider.value() != 50, "right-button + wheel must adjust the slider"
print("gude")
+29
View File
@@ -0,0 +1,29 @@
"""The console-log pop-out is a second view on the same emitter: history is
copied on open, live lines reach both views, and clear() empties both."""
from aare.gui.panels.log_panel import LogDock
def test_log_popout_mirrors_and_clears(qtbot):
dock = LogDock()
qtbot.addWidget(dock)
dock.emitter.message.emit("first line")
assert "first line" in dock.view.toPlainText()
dock._open_popout()
assert dock._popout is not None
assert dock._popout.isVisible()
# History copied on open, live lines reach both views.
assert "first line" in dock._popout_view.toPlainText()
dock.emitter.message.emit("second line")
assert "second line" in dock.view.toPlainText()
assert "second line" in dock._popout_view.toPlainText()
# Reopening reuses the window instead of stacking mirrors.
popout = dock._popout
dock._open_popout()
assert dock._popout is popout
dock.clear()
assert dock.view.toPlainText() == ""
assert dock._popout_view.toPlainText() == ""
+124
View File
@@ -121,3 +121,127 @@ def test_sample_queue_model_flags(sample_list):
model = SampleQueueSpreadsheet(samples=sample_list[:2])
flags = model.flags(model.index(0, 0))
assert flags & Qt.ItemFlag.ItemIsDropEnabled
# --- Status logic of the combined dewar/queue view ---------------------------
# The dewar table doubles as the queue view: the frozen "#" column carries a
# status fill (mounted > queued > flagged > measured) and the chip row filters
# by status. This is the logic a local contact trusts at a glance, so it gets
# its own tests.
def _status(model, row):
brush = model.data(model.index(row, 0), Qt.ItemDataRole.BackgroundRole)
return None if brush is None else brush.color().name().lower()
def _row_of(model, db_id):
return next(r for r in range(model.rowCount()) if model.get_id(r).db_id == db_id)
@pytest.fixture
def status_model(sample_list):
from aarecommon.models.models import DewarAddress, SampleShortInfo
# A measured sample: rotation_count > 1 (exactly 1 must NOT count).
sample_list.append(
SampleShortInfo(
db_id=4,
puck_name="P3",
dewar_name="D3",
sample_name="S4",
run_number=4,
user="U1",
pin=4,
rotation_count=2,
location=DewarAddress(segment="B", pos=1),
)
)
model = UserSampleSpreadsheet(samples=sample_list)
model.set_show_all_pgroups(True)
return model
def test_status_color_priority(status_model):
from aare.gui.styles import (
SAMPLE_ROW_QUEUED_BG,
SAMPLE_STATUS_FLAGGED_BG,
SAMPLE_STATUS_MEASURED_BG,
SAMPLE_STATUS_QUEUED_BG,
)
model = status_model
assert _status(model, _row_of(model, 1)) is None
model.set_queued_ids({1})
model.set_flagged(1, True)
# Queued beats flagged in the All view.
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower()
model.set_queued_ids(set())
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower()
# Measured is automatic: rotation_count 2 counts, the fixture's 1-3 don't.
assert _status(model, _row_of(model, 4)) == SAMPLE_STATUS_MEASURED_BG.lower()
assert _status(model, _row_of(model, 2)) is None
# Mounted always wins.
model.updateCurrentSample(current_puck="P1", current_sample=1)
assert _status(model, _row_of(model, 1)) == SAMPLE_ROW_QUEUED_BG.lower()
def test_status_filter_selects_rows(status_model):
model = status_model
model.set_queued_ids({1, 2})
model.set_flagged(3, True)
model.set_status_filter("queued")
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {1, 2}
model.set_status_filter("flagged")
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {3}
model.set_status_filter("measured")
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {4}
model.set_status_filter(None)
assert model.rowCount() == 4
def test_status_tints_are_context_dependent(status_model):
from aare.gui.styles import SAMPLE_STATUS_FLAGGED_BG, SAMPLE_STATUS_QUEUED_BG
model = status_model
model.set_queued_ids({1, 2})
model.set_flagged(1, True)
# Queued view: own tint suppressed, only the also-flagged mark shows.
model.set_status_filter("queued")
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_FLAGGED_BG.lower()
assert _status(model, _row_of(model, 2)) is None
# Flagged view: a re-queued sample wears the queued mark.
model.set_status_filter("flagged")
assert _status(model, _row_of(model, 1)) == SAMPLE_STATUS_QUEUED_BG.lower()
def test_status_sets_refilter_while_chip_active(status_model):
model = status_model
model.set_status_filter("queued")
assert model.rowCount() == 0
model.set_queued_ids({2})
assert {model.get_id(r).db_id for r in range(model.rowCount())} == {2}
def test_status_column_is_display_only(status_model):
model = status_model
assert model.data(model.index(0, 0), Qt.ItemDataRole.DisplayRole) == 1
before = [model.get_id(r).db_id for r in range(model.rowCount())]
model.sort(0, Qt.SortOrder.DescendingOrder) # no-op on the "#" column
assert [model.get_id(r).db_id for r in range(model.rowCount())] == before
def test_mime_data_round_trips_for_chip_drops(status_model):
from aarecommon.models.models import SampleShortInfoList
model = status_model
payload = model.mimeData([model.index(0, 1), model.index(1, 1)])
samples = SampleShortInfoList.model_validate_json(payload.text())
assert len(samples.s) == 2
assert samples.s[0].db_id == model.get_id(0).db_id
+171
View File
@@ -0,0 +1,171 @@
"""PopoutWindow replaces dock floating: an additional top-level window whose
edge band resizes, whose close only hides, and whose first show lands near
the cursor. DockTitleBar puts the pop-out button next to the close box."""
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
from PySide6.QtGui import QMouseEvent
from PySide6.QtWidgets import QDockWidget, QLabel
from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow
def _window(qtbot):
w = PopoutWindow("test", QLabel("content"))
qtbot.addWidget(w)
w.resize(400, 300)
return w
def test_edges_at(qtbot):
w = _window(qtbot)
band = PopoutWindow.RESIZE_MARGIN + PopoutWindow.OUTER_GRIP
assert w._edges_at(QPoint(band - 1, 150)) == Qt.Edge.LeftEdge
assert w._edges_at(QPoint(200, band - 1)) == Qt.Edge.TopEdge
assert w._edges_at(QPoint(399, 299)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge)
assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0)
def test_cursor_for(qtbot):
w = _window(qtbot)
assert w._cursor_for(Qt.Edge.LeftEdge) == Qt.CursorShape.SizeHorCursor
assert w._cursor_for(Qt.Edge.BottomEdge) == Qt.CursorShape.SizeVerCursor
assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor
assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor
assert w._cursor_for(Qt.Edge(0)) is None
def test_close_hides_and_keeps_geometry(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
w.move(120, 130)
geometry = w.geometry()
w.close()
assert not w.isVisible()
# Reopen: the first-show placement must not run again (geometry kept).
w.show()
assert w._placed
assert w.geometry() == geometry
def test_manual_resize_fallback(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
w.resize(400, 300)
start = w.geometry()
grab = QPointF(399.0, 299.0) # bottom-right band
press = QMouseEvent(
QEvent.Type.MouseButtonPress,
grab,
w.mapToGlobal(grab.toPoint()).toPointF(),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
w.mousePressEvent(press)
if not w._manual_edges:
# The platform accepted startSystemResize; the manual path is not
# reachable here, and the press must not have moved the window.
assert w.geometry() == start
return
move_to = grab + QPointF(40.0, 25.0)
move = QMouseEvent(
QEvent.Type.MouseMove,
move_to,
w.mapToGlobal(move_to.toPoint()).toPointF(),
Qt.MouseButton.NoButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
w.mouseMoveEvent(move)
assert w.width() == start.width() + 40
assert w.height() == start.height() + 25
release = QMouseEvent(
QEvent.Type.MouseButtonRelease,
move_to,
w.mapToGlobal(move_to.toPoint()).toPointF(),
Qt.MouseButton.LeftButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
w.mouseReleaseEvent(release)
assert not w._manual_edges
def test_hover_sets_resize_cursor(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
def hover(pos):
w.mouseMoveEvent(
QMouseEvent(
QEvent.Type.MouseMove,
pos,
w.mapToGlobal(pos.toPoint()).toPointF(),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
)
hover(QPointF(2.0, 150.0))
assert w.cursor().shape() == Qt.CursorShape.SizeHorCursor
hover(QPointF(200.0, 150.0))
assert w.cursor().shape() == Qt.CursorShape.ArrowCursor
def test_dock_title_bar_buttons(qtbot):
dock = QDockWidget("Console Log")
qtbot.addWidget(dock)
opened = []
bar = DockTitleBar(dock, on_popout=lambda: opened.append(True))
dock.setTitleBarWidget(bar)
dock.show()
qtbot.waitExposed(dock)
qtbot.mouseClick(bar.popout_button, Qt.MouseButton.LeftButton)
assert opened == [True]
assert dock.isVisible()
def test_manual_resize_from_top_left(qtbot):
w = _window(qtbot)
w.show()
qtbot.waitExposed(w)
w.resize(400, 300)
# Drive the manual path directly; whether startSystemResize is available
# is platform luck and the top/left arithmetic deserves coverage either way.
w._manual_edges = Qt.Edge.LeftEdge | Qt.Edge.TopEdge
w._press_global = w.mapToGlobal(QPoint(2, 2))
w._press_geom = w.geometry()
move_to = QPointF(2.0 + 30.0, 2.0 + 20.0)
w.mouseMoveEvent(
QMouseEvent(
QEvent.Type.MouseMove,
move_to,
w.mapToGlobal(move_to.toPoint()).toPointF(),
Qt.MouseButton.NoButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
)
)
assert w.width() == 400 - 30
assert w.height() == 300 - 20
def test_paint_border_knob(qtbot, monkeypatch):
from PySide6.QtGui import QPixmap
from aare.gui.widgets import popout_window as mod
w = _window(qtbot)
# Default FRAME_L1_WIDTH "0px": render must take the paint-nothing path.
w.render(QPixmap(w.size()))
# With a visible level-1 border the painter path runs.
monkeypatch.setattr(mod, "FRAME_L1_WIDTH", "2px")
w.render(QPixmap(w.size()))
+134
View File
@@ -0,0 +1,134 @@
"""The combined sample dock: chip row filters the table by status, chips
double as drop targets for queue/flag relabeling, and a pop-out panel shares
the docked panel's model so both stay in sync without wiring."""
import pytest
from aarecommon.models.models import DewarAddress, SampleShortInfo, SampleShortInfoList
from aare.gui.panels.tell_sample_panel import TellSamplePanel
@pytest.fixture
def samples():
return SampleShortInfoList(
s=[
SampleShortInfo(
db_id=i,
puck_name=f"P{i}",
dewar_name="D1",
sample_name=f"S{i}",
run_number=i,
user="U1",
pin=i,
location=DewarAddress(segment="A", pos=i),
)
for i in (1, 2, 3)
]
)
@pytest.fixture
def panel(qtbot, samples):
panel = TellSamplePanel(samples=samples)
qtbot.addWidget(panel)
panel.table_model.set_show_all_pgroups(True)
return panel
def _chip(panel, key):
return next(c for c in panel.status_chips.buttons() if c.property("status_key") == key)
def test_chip_click_drives_the_status_filter(panel):
panel.table_model.set_queued_ids({2})
_chip(panel, "queued").click()
assert panel.table_model.status_filter == "queued"
assert panel.table_model.rowCount() == 1
_chip(panel, None).click()
assert panel.table_model.status_filter is None
assert panel.table_model.rowCount() == 3
def test_set_status_chip_syncs_without_filtering(panel):
panel.set_status_chip("flagged")
assert _chip(panel, "flagged").isChecked()
# Sync only checks the chip; it must not fire the filter.
assert panel.table_model.status_filter is None
def test_queued_chip_drop_relays_to_the_queue(panel, qtbot, samples):
chip = _chip(panel, "queued")
with qtbot.waitSignal(panel.add_to_queue) as blocker:
chip.samples_dropped.emit(samples)
assert [s.db_id for s in blocker.args[0].s] == [1, 2, 3]
def test_flagged_chip_drop_flags_in_the_model(panel, samples):
panel._flag_dropped_samples(SampleShortInfoList(s=samples.s[:2]))
assert panel.table_model.flagged_ids == {1, 2}
def test_popout_panel_shares_the_model(panel, qtbot):
popout = TellSamplePanel(model=panel.table_model)
qtbot.addWidget(popout)
assert popout.table_model is panel.table_model
panel.table_model.set_flagged(3, True)
assert 3 in popout.table_model.flagged_ids
def test_new_sample_list_updates_rows(panel, samples):
extra = samples.s + [
SampleShortInfo(
db_id=9,
puck_name="P9",
dewar_name="D2",
sample_name="S9",
run_number=9,
user="U2",
pin=9,
location=DewarAddress(segment="B", pos=1),
)
]
panel.new_sample_list(SampleShortInfoList(s=extra))
assert panel.table_model.rowCount() == 4
def test_queue_drop_chip_accepts_sample_payloads(panel, qtbot, samples):
from PySide6.QtCore import QMimeData, QPointF, Qt
from PySide6.QtGui import QDropEvent
from aare.gui.panels.tell_sample_panel import QueueDropChip
chip = next(c for c in panel.status_chips.buttons() if isinstance(c, QueueDropChip))
# The event only borrows the QMimeData (C++ pointer), so the mime must
# outlive the dropEvent call — hence created in the test's scope.
def drop(mime):
return QDropEvent(
QPointF(1, 1),
Qt.DropAction.CopyAction,
mime,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
good = QMimeData()
good.setText(samples.model_dump_json())
with qtbot.waitSignal(chip.samples_dropped):
chip.dropEvent(drop(good))
# A non-sample payload is ignored, not crashed on.
bad = QMimeData()
bad.setText("not json")
with qtbot.assertNotEmitted(chip.samples_dropped):
chip.dropEvent(drop(bad))
def test_selected_samples_follow_the_click(panel):
view = panel.table_view
view.selectRow(0)
row_ids = [panel.table_model.get_id(r).db_id for r in range(3)]
# Click inside the selection: the selection is acted on.
assert [s.db_id for s in panel._selected_samples(0)] == [row_ids[0]]
# Click outside the selection: only the clicked row is acted on.
assert [s.db_id for s in panel._selected_samples(2)] == [row_ids[2]]
+74
View File
@@ -0,0 +1,74 @@
"""The wheel guard is motor protection: a bare wheel over a value widget must
never change the value (it scrolls the page instead); adjusting requires the
deliberate right-button + wheel gesture."""
import pytest
from PySide6.QtCore import QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import QApplication, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget
from aare.gui.widgets.wheel_value_guard import WheelValueGuard
@pytest.fixture
def guard(qapp):
guard = WheelValueGuard()
qapp.installEventFilter(guard)
yield guard
qapp.removeEventFilter(guard)
def _wheel(buttons):
return QWheelEvent(
QPointF(5, 5),
QPointF(5, 5),
QPoint(0, 0),
QPoint(0, 120),
buttons,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
def test_bare_wheel_does_not_adjust(guard, qtbot):
slider = QSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(slider)
slider.setRange(0, 100)
slider.setValue(50)
QApplication.sendEvent(slider, _wheel(Qt.MouseButton.NoButton))
assert slider.value() == 50
def test_right_button_wheel_adjusts(guard, qtbot):
slider = QSlider(Qt.Orientation.Horizontal)
qtbot.addWidget(slider)
slider.setRange(0, 100)
slider.setValue(50)
QApplication.sendEvent(slider, _wheel(Qt.MouseButton.RightButton))
assert slider.value() != 50
def test_bare_wheel_scrolls_the_enclosing_area(guard, qtbot):
area = QScrollArea()
qtbot.addWidget(area)
content = QWidget()
layout = QVBoxLayout(content)
spin = QSpinBox()
spin.setRange(0, 100)
spin.setValue(50)
layout.addWidget(spin)
# Tall filler so the area has something to scroll.
filler = QWidget()
filler.setFixedHeight(2000)
layout.addWidget(filler)
area.setWidget(content)
area.resize(200, 200)
area.show()
bar = area.verticalScrollBar()
bar.setValue(bar.maximum() // 2)
before = bar.value()
QApplication.sendEvent(spin, _wheel(Qt.MouseButton.NoButton))
assert spin.value() == 50
assert bar.value() != before