"""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") # The module disables itself under the offscreen platform so widget grabs in the # other suites capture settled frames; these tests are about the animation, so # they opt back in. os.environ["DOCTOR_MOTION"] = "on" import pytest from PySide6.QtCore import QEvent, QPoint, QPointF, Qt from PySide6.QtGui import QWheelEvent from PySide6.QtWidgets import ( QApplication, QLabel, QScrollArea, QStackedWidget, QVBoxLayout, QWidget, ) from doctor_workstation.ui import motion @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) -> QWheelEvent: return QWheelEvent( QPointF(50, 50), QPointF(50, 50), QPoint(0, 0), QPoint(0, delta), Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.NoScrollPhase, 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()