Files
zyt/app/tests/test_motion.py
T

512 lines
16 KiB
Python
Executable File

"""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()