"""Contract for the shared motion system. The product previously had no `QPropertyAnimation` at all, so these tests exist to keep the two things that make added motion a liability from creeping back: an animation that outlives or destroys the object it is animating, and a graphics effect left attached after a fade, which would quietly move a whole subtree onto Qt's offscreen composite path for the rest of the session. """ from __future__ import annotations import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtCore import QAbstractAnimation, QEvent, QPoint, QPointF, QPropertyAnimation, Qt from PySide6.QtGui import QKeyEvent, QWheelEvent from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QGraphicsDropShadowEffect, QLabel, QListWidget, QPlainTextEdit, QScrollArea, QStackedWidget, QVBoxLayout, QWidget, ) from doctor_workstation.ui import motion @pytest.fixture(autouse=True) def enable_motion(monkeypatch: pytest.MonkeyPatch) -> None: # Opt in per test so collecting this module does not animate other suites' # screenshots or leak motion settings across the shared QApplication. monkeypatch.setenv("DOCTOR_MOTION", "on") @pytest.fixture(scope="module") def application() -> QApplication: return QApplication.instance() or QApplication([]) def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]: host = QWidget() host.resize(320, 120) layout = QVBoxLayout(host) stack = QStackedWidget() first, second = QLabel("A"), QLabel("B") stack.addWidget(first) stack.addWidget(second) layout.addWidget(stack) host.show() application.processEvents() return host, stack, second def test_page_transition_fades_and_settles_upward(application: QApplication) -> None: host, stack, incoming = _stack(application) origin = incoming.pos() motion.switch_stack(stack, 1) animations = incoming._doctor_motion rise, fade = animations["enter"], animations["fade"] assert rise.duration() == fade.duration() == motion.BASE samples = [] for at in (0, motion.BASE // 2, motion.BASE - 1): rise.setCurrentTime(at) fade.setCurrentTime(at) samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity())) offsets = [offset for offset, _ in samples] opacities = [opacity for _, opacity in samples] assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up" assert opacities == sorted(opacities), "opacity must rise monotonically" assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0) host.close() def test_fade_detaches_its_graphics_effect_when_it_finishes( application: QApplication, ) -> None: """A left-behind opacity effect is a permanent frame-rate tax, not a leak.""" host, stack, incoming = _stack(application) motion.switch_stack(stack, 1) assert incoming.graphicsEffect() is not None fade = incoming._doctor_motion["fade"] fade.setCurrentTime(fade.duration()) # The detach is deferred by one event-loop turn on purpose, so that the # effect is not destroyed from inside the signal it is emitting. application.processEvents() application.processEvents() assert incoming.graphicsEffect() is None host.close() def test_motion_can_be_turned_off_without_leaving_widgets_mid_state( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("DOCTOR_MOTION", "off") assert motion.reduced_motion() host, stack, incoming = _stack(application) origin = incoming.pos() motion.switch_stack(stack, 1) assert stack.currentIndex() == 1 assert incoming.pos() == origin assert incoming.graphicsEffect() is None host.close() def _scroll_area(application: QApplication) -> QScrollArea: area = QScrollArea() area.setWidgetResizable(True) area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300)))) area.resize(300, 200) area.show() application.processEvents() return area def _wheel( delta: int, *, horizontal: int = 0, pixels: int = 0, modifiers: Qt.KeyboardModifier = Qt.KeyboardModifier.NoModifier, phase: Qt.ScrollPhase = Qt.ScrollPhase.NoScrollPhase, ) -> QWheelEvent: return QWheelEvent( QPointF(50, 50), QPointF(50, 50), QPoint(0, pixels), QPoint(horizontal, delta), Qt.MouseButton.NoButton, modifiers, phase, False, ) def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll bar = area.verticalScrollBar() assert scroller.eventFilter(area.viewport(), _wheel(-120)) animation = scroller._animation assert animation.endValue() == motion.SCROLL_STEP values = [] for at in (0, 60, 120, animation.duration() - 1): animation.setCurrentTime(at) values.append(bar.value()) assert values[0] == 0 assert values == sorted(values) assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early" animation.stop() area.close() def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area( application: QApplication, ) -> None: """Swallowing the wheel at the extremes is what makes nested panes feel stuck.""" area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll bar = area.verticalScrollBar() bar.setValue(bar.minimum()) application.processEvents() assert not scroller.eventFilter(area.viewport(), _wheel(120)) assert scroller.eventFilter(area.viewport(), _wheel(-120)) scroller._animation.stop() bar.setValue(bar.maximum()) application.processEvents() assert not scroller.eventFilter(area.viewport(), _wheel(-120)) area.close() def test_zoom_gestures_are_left_alone(application: QApplication) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll ctrl_wheel = QWheelEvent( QPointF(50, 50), QPointF(50, 50), QPoint(0, 0), QPoint(0, -120), Qt.MouseButton.NoButton, Qt.KeyboardModifier.ControlModifier, Qt.ScrollPhase.NoScrollPhase, False, ) assert not scroller.eventFilter(area.viewport(), ctrl_wheel) area.close() def test_install_is_idempotent(application: QApplication) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) first = area._doctor_smooth_scroll motion.install_smooth_scroll(area) assert area._doctor_smooth_scroll is first area.close() def test_reading_surfaces_get_smooth_scrolling_from_the_theme( application: QApplication, ) -> None: """A QScrollArea should not have to opt in page by page.""" from doctor_workstation.ui.theme import apply_theme apply_theme(application) area = QScrollArea() area.setWidget(QLabel("content")) area.show() area.ensurePolished() application.sendEvent(area, QEvent(QEvent.Type.Polish)) application.processEvents() assert getattr(area, "_doctor_smooth_scroll", None) is not None area.close() def test_repeated_fade_continues_from_current_opacity(application: QApplication) -> None: widget = QWidget() motion.fade_in(widget) first = widget._doctor_motion["fade"] first.setCurrentTime(motion.BASE // 2) opacity = widget.graphicsEffect().opacity() motion.fade_in(widget) assert widget.graphicsEffect().opacity() == pytest.approx(opacity) assert widget._doctor_motion["fade"].startValue() == pytest.approx(opacity) assert first.state() == QAbstractAnimation.State.Stopped widget.close() def test_fade_out_starts_at_full_opacity(application: QApplication) -> None: widget = QWidget() widget.show() motion.fade_out(widget) assert widget.graphicsEffect().opacity() == pytest.approx(1.0) widget.close() def test_reversing_fade_does_not_run_cancelled_hide_callback(application: QApplication) -> None: widget = QWidget() widget.show() completed = [] motion.fade_out(widget, on_finished=lambda: completed.append(True)) outgoing = widget._doctor_motion["fade"] outgoing.setCurrentTime(motion.FAST // 2) opacity = widget.graphicsEffect().opacity() motion.fade_in(widget) incoming = widget._doctor_motion["fade"] assert incoming.startValue() == pytest.approx(opacity) incoming.setCurrentTime(incoming.duration()) application.processEvents() assert widget.isVisible() assert not completed assert widget.graphicsEffect() is None widget.close() def test_previous_fade_cleanup_does_not_delete_reentrant_fade(application: QApplication) -> None: widget = QWidget() widget.show() motion.fade_out(widget, on_finished=lambda: motion.fade_in(widget)) outgoing = widget._doctor_motion["fade"] outgoing.setCurrentTime(outgoing.duration()) incoming = widget._doctor_motion["fade"] effect = widget.graphicsEffect() application.processEvents() assert widget.graphicsEffect() is effect assert incoming.state() == QAbstractAnimation.State.Running incoming.setCurrentTime(incoming.duration()) application.processEvents() assert widget.graphicsEffect() is None assert widget.isVisible() widget.close() def test_replaced_and_completed_animations_are_released(application: QApplication) -> None: widget = QWidget() destroyed = [] for _ in range(10): motion.fade_in(widget) widget._doctor_motion["fade"].destroyed.connect(lambda: destroyed.append(True)) fade = widget._doctor_motion["fade"] fade.setCurrentTime(fade.duration()) application.processEvents() application.sendPostedEvents(None, QEvent.Type.DeferredDelete) assert not widget._doctor_motion assert not widget.findChildren(QPropertyAnimation) assert len(destroyed) == 10 widget.close() def test_fades_preserve_existing_graphics_effects(application: QApplication) -> None: widget = QWidget() shadow = QGraphicsDropShadowEffect(widget) widget.setGraphicsEffect(shadow) motion.fade_in(widget) assert widget.graphicsEffect() is shadow motion.fade_out(widget) application.processEvents() assert widget.graphicsEffect() is shadow assert widget.isHidden() widget.close() def test_repeated_enter_does_not_accumulate_position_offset(application: QApplication) -> None: host = QWidget() widget = QWidget(host) origin = QPoint(10, 20) widget.move(origin) motion.enter(widget) widget._doctor_motion["enter"].setCurrentTime(motion.BASE // 3) position = widget.pos() motion.enter(widget) assert widget.pos() == position arrival = widget._doctor_motion["enter"] assert arrival.endValue() == origin arrival.setCurrentTime(arrival.duration()) assert widget.pos() == origin host.close() def test_disabling_motion_settles_an_active_entry( application: QApplication, monkeypatch: pytest.MonkeyPatch ) -> None: host = QWidget() widget = QWidget(host) origin = QPoint(10, 20) widget.move(origin) motion.enter(widget) monkeypatch.setenv("DOCTOR_MOTION", "off") motion.enter(widget) application.processEvents() assert widget.pos() == origin assert widget.graphicsEffect() is None assert not widget._doctor_motion host.close() def test_wheel_reversal_discards_pending_travel(application: QApplication) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll bar = area.verticalScrollBar() bar.setValue(500) for _ in range(4): scroller.eventFilter(area.viewport(), _wheel(-120)) scroller._animation.setCurrentTime(30) position = bar.value() scroller.eventFilter(area.viewport(), _wheel(120)) assert scroller._animation.startValue() == position assert scroller._animation.endValue() == position - motion.SCROLL_STEP scroller._animation.setCurrentTime(60) assert bar.value() < position area.close() @pytest.mark.parametrize("interaction", ["set_value", "keyboard", "slider_press", "range_change"]) def test_direct_scroll_interaction_cancels_pending_wheel( application: QApplication, interaction: str ) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll bar = area.verticalScrollBar() scroller.eventFilter(area.viewport(), _wheel(-120)) scroller._animation.setCurrentTime(30) if interaction == "set_value": bar.setValue(500) elif interaction == "keyboard": application.sendEvent( area, QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Down, Qt.KeyboardModifier.NoModifier), ) elif interaction == "slider_press": bar.setSliderDown(True) else: bar.setRange(0, 50) assert scroller._animation.state() == QAbstractAnimation.State.Stopped assert scroller._target == bar.value() if interaction == "set_value": scroller.eventFilter(area.viewport(), _wheel(-120)) assert scroller._animation.endValue() == 500 + motion.SCROLL_STEP area.close() @pytest.mark.parametrize( "wheel_options", [ {"pixels": -8}, {"phase": Qt.ScrollPhase.ScrollUpdate}, {"phase": Qt.ScrollPhase.ScrollMomentum}, {"modifiers": Qt.KeyboardModifier.ControlModifier}, {"modifiers": Qt.KeyboardModifier.ShiftModifier}, {"horizontal": -120}, ], ) def test_native_gestures_cancel_pending_wheel( application: QApplication, wheel_options: dict ) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll scroller.eventFilter(area.viewport(), _wheel(-120)) scroller._animation.setCurrentTime(30) delta = 0 if "horizontal" in wheel_options else -120 assert not scroller.eventFilter(area.viewport(), _wheel(delta, **wheel_options)) assert scroller._animation.state() == QAbstractAnimation.State.Stopped assert scroller._target == area.verticalScrollBar().value() area.close() @pytest.mark.parametrize("kind", ["items", "text_blocks"]) def test_non_pixel_scrollbars_keep_native_wheel_steps(application: QApplication, kind: str) -> None: if kind == "items": area = QListWidget() area.addItems([f"row {index}" for index in range(300)]) area.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem) else: area = QPlainTextEdit("\n".join(f"line {index}" for index in range(300))) area.resize(300, 200) area.show() application.processEvents() motion.install_smooth_scroll(area) assert not area._doctor_smooth_scroll.eventFilter(area.viewport(), _wheel(-120)) application.sendEvent(area.viewport(), _wheel(-120)) assert 0 < area.verticalScrollBar().value() < motion.SCROLL_STEP area.close() def test_pending_scroll_reaches_edge_before_handing_wheel_back(application: QApplication) -> None: area = _scroll_area(application) motion.install_smooth_scroll(area) scroller = area._doctor_smooth_scroll bar = area.verticalScrollBar() bar.setValue(bar.maximum() - 20) assert scroller.eventFilter(area.viewport(), _wheel(-120)) assert scroller.eventFilter(area.viewport(), _wheel(-120)) scroller._animation.setCurrentTime(scroller._animation.duration()) assert not scroller.eventFilter(area.viewport(), _wheel(-120)) area.close() def test_horizontal_scroller_honors_horizontal_and_shift_wheel(application: QApplication) -> None: area = QScrollArea() content = QWidget() content.resize(2000, 1000) area.setWidget(content) area.resize(300, 200) motion.install_smooth_scroll(area, orientation=Qt.Orientation.Horizontal) area.show() application.processEvents() scroller = area._doctor_smooth_scroll assert not scroller.eventFilter(area.viewport(), _wheel(-120)) assert scroller.eventFilter(area.viewport(), _wheel(0, horizontal=-120)) scroller._animation.setCurrentTime(scroller._animation.duration()) assert area.horizontalScrollBar().value() == motion.SCROLL_STEP assert area.verticalScrollBar().value() == 0 assert scroller.eventFilter( area.viewport(), _wheel(-120, modifiers=Qt.KeyboardModifier.ShiftModifier) ) assert scroller._animation.endValue() == motion.SCROLL_STEP * 2 area.close()