512 lines
16 KiB
Python
Executable File
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()
|