Files
zyt/app/tests/test_motion.py
T
2026-09-07 12:30:42 +08:00

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