diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py index 8d224e49..ace02255 100644 --- a/src/aare/gui/widgets/popout_window.py +++ b/src/aare/gui/widgets/popout_window.py @@ -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") diff --git a/src/aare/gui/widgets/wheel_value_guard.py b/src/aare/gui/widgets/wheel_value_guard.py index 7697de8e..b4975fc9 100644 --- a/src/aare/gui/widgets/wheel_value_guard.py +++ b/src/aare/gui/widgets/wheel_value_guard.py @@ -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") diff --git a/tests/unit/gui/test_log_panel.py b/tests/unit/gui/test_log_panel.py new file mode 100644 index 00000000..9c88dc14 --- /dev/null +++ b/tests/unit/gui/test_log_panel.py @@ -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() == "" diff --git a/tests/unit/gui/test_models.py b/tests/unit/gui/test_models.py index c72ed78c..034374cc 100644 --- a/tests/unit/gui/test_models.py +++ b/tests/unit/gui/test_models.py @@ -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 diff --git a/tests/unit/gui/test_popout_window.py b/tests/unit/gui/test_popout_window.py new file mode 100644 index 00000000..f42fdca2 --- /dev/null +++ b/tests/unit/gui/test_popout_window.py @@ -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())) diff --git a/tests/unit/gui/test_tell_sample_panel.py b/tests/unit/gui/test_tell_sample_panel.py new file mode 100644 index 00000000..c8882ad9 --- /dev/null +++ b/tests/unit/gui/test_tell_sample_panel.py @@ -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]] diff --git a/tests/unit/gui/test_wheel_value_guard.py b/tests/unit/gui/test_wheel_value_guard.py new file mode 100644 index 00000000..68d760a9 --- /dev/null +++ b/tests/unit/gui/test_wheel_value_guard.py @@ -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