229 lines
6.9 KiB
Python
229 lines
6.9 KiB
Python
"""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()
|