GitHub Viewer
"""Tests for ``pn.Easing``: descriptors, the Python evaluator, and the wire form."""
from __future__ import annotations
import time
from typing import Any
import pytest
from pythonnative.animated import EASING_NAMES, Animated, AnimatedValue, Easing, EasingSpec
from pythonnative.native_views import set_backend
from pythonnative.testing import FakeBackend
# ======================================================================
# Descriptors
# ======================================================================
def test_easing_namespace_exposes_frozen_specs() -> None:
assert EASING_NAMES == ("linear", "ease", "ease_in", "ease_out", "ease_in_out", "quad", "cubic", "bounce")
for name in EASING_NAMES:
spec = getattr(Easing, name)
assert isinstance(spec, EasingSpec)
assert spec.name == name and spec.points is None
assert spec.to_wire() == name
with pytest.raises(Exception):
Easing.linear.name = "other" # type: ignore[misc]
def test_bezier_descriptor_carries_control_points() -> None:
spec = Easing.bezier(0.2, 0.8, 0.2, 1.0)
assert spec == EasingSpec("bezier", (0.2, 0.8, 0.2, 1.0))
assert spec.to_wire() == [0.2, 0.8, 0.2, 1.0]
assert repr(spec) == "Easing.bezier(0.2, 0.8, 0.2, 1.0)"
assert repr(Easing.ease_out) == "Easing.ease_out"
def test_bezier_validation() -> None:
with pytest.raises(ValueError):
Easing.bezier(1.5, 0.0, 0.5, 1.0) # x1 outside [0, 1]
with pytest.raises(ValueError):
Easing.bezier(0.5, 0.0, -0.1, 1.0) # x2 outside [0, 1]
# y values may overshoot (anticipation and overshoot curves).
Easing.bezier(0.34, 1.56, 0.64, 1.0)
with pytest.raises(ValueError):
EasingSpec("bezier")
with pytest.raises(ValueError):
EasingSpec("wobble")
with pytest.raises(ValueError):
EasingSpec("linear", (0.0, 0.0, 1.0, 1.0))
# ======================================================================
# Evaluator
# ======================================================================
def test_named_curves_evaluate_to_reference_values() -> None:
assert Easing.linear.evaluate(0.3) == 0.3
assert Easing.quad.evaluate(0.5) == 0.25
assert Easing.cubic.evaluate(0.5) == 0.125
assert Easing.bounce.evaluate(0.0) == 0.0
assert abs(Easing.bounce.evaluate(1.0) - 1.0) < 1e-12
# Penner bounce-out midway through the first arc.
assert abs(Easing.bounce.evaluate(0.2) - 7.5625 * 0.2 * 0.2) < 1e-12
for name in EASING_NAMES:
spec = getattr(Easing, name)
assert spec.evaluate(0.0) == 0.0
assert abs(spec.evaluate(1.0) - 1.0) < 1e-9
def test_ease_family_matches_react_native_definitions() -> None:
# ease == bezier(0.42, 0, 1, 1) (React Native's Easing.ease); in/out/in_out derive from it.
for t in (0.1, 0.25, 0.5, 0.75, 0.9):
assert abs(Easing.ease.evaluate(t) - Easing.bezier(0.42, 0.0, 1.0, 1.0).evaluate(t)) < 1e-9
assert abs(Easing.ease_in.evaluate(t) - Easing.ease.evaluate(t)) < 1e-9
# Easing.out(f)(t) == 1 - f(1 - t)
assert abs(Easing.ease_out.evaluate(t) - (1.0 - Easing.ease.evaluate(1.0 - t))) < 1e-6
assert Easing.ease_in.evaluate(0.5) < 0.5 < Easing.ease_out.evaluate(0.5)
assert abs(Easing.ease_in_out.evaluate(0.5) - 0.5) < 1e-6
assert Easing.ease_in_out.evaluate(0.25) < 0.25 and Easing.ease_in_out.evaluate(0.75) > 0.75
def test_cubic_bezier_solver_inverts_the_parametric_curve() -> None:
"""For sampled parameter values, evaluate(x(t)) must return y(t)."""
def component(t: float, p1: float, p2: float) -> float:
inv = 1.0 - t
return 3 * inv * inv * t * p1 + 3 * inv * t * t * p2 + t * t * t
curves = [
(0.2, 0.8, 0.2, 1.0),
(0.42, 0.0, 0.58, 1.0),
(0.34, 1.56, 0.64, 1.0),
(1.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 1.0, 1.0),
]
for x1, y1, x2, y2 in curves:
spec = Easing.bezier(x1, y1, x2, y2)
for i in range(1, 50):
t = i / 50
x = component(t, x1, x2)
y = component(t, y1, y2)
assert abs(spec.evaluate(x) - y) < 1e-5, (x1, y1, x2, y2, t)
def test_bezier_clamps_progress_outside_unit_interval() -> None:
spec = Easing.bezier(0.2, 0.8, 0.2, 1.0)
assert spec.evaluate(-0.5) == 0.0
assert spec.evaluate(1.5) == 1.0
# ======================================================================
# Animated.timing integration
# ======================================================================
class _RecordingBackend(FakeBackend):
def __init__(self) -> None:
super().__init__()
self.native_started: list = []
def start_animation(self, tag: int, anim_id: int, prop_name: str, spec: Any) -> bool:
self.native_started.append((tag, anim_id, prop_name, dict(spec)))
return True
def cancel_animation(self, tag: int, anim_id: int) -> Any:
return None
@pytest.fixture
def backend(): # type: ignore[no-untyped-def]
backend = _RecordingBackend()
set_backend(backend)
try:
yield backend
finally:
set_backend(None)
def test_timing_rejects_unknown_easing_names() -> None:
v = AnimatedValue(0.0)
with pytest.raises(ValueError, match="Unknown easing"):
Animated.timing(v, to=1.0, easing="ease_in_quad")
with pytest.raises(ValueError):
Animated.timing(v, to=1.0, easing="eas_in_out")
with pytest.raises(TypeError):
Animated.timing(v, to=1.0, easing=42) # type: ignore[arg-type]
def test_timing_serializes_named_and_bezier_easings(backend: _RecordingBackend) -> None:
v = AnimatedValue(0.0)
v.attach(7, "opacity")
Animated.timing(v, to=1.0, duration=500, easing="ease_out").start()
Animated.timing(v, to=1.0, duration=500, easing=Easing.bounce).start()
Animated.timing(v, to=1.0, duration=500, easing=Easing.bezier(0.2, 0.8, 0.2, 1.0)).start()
Animated.timing(v, to=1.0, duration=500).start()
wire = [spec["easing"] for _tag, _id, _prop, spec in backend.native_started]
assert wire == ["ease_out", "bounce", [0.2, 0.8, 0.2, 1.0], "ease_in_out"]
def test_callable_easing_disables_the_native_driver(backend: _RecordingBackend) -> None:
v = AnimatedValue(0.0)
v.attach(7, "opacity")
handle = Animated.timing(v, to=1.0, duration=30, easing=lambda t: t * t)
handle.start()
assert backend.native_started == []
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and v.value < 0.99:
time.sleep(0.01)
assert abs(v.value - 1.0) < 0.05
@pytest.mark.asyncio
async def test_timing_with_bezier_easing_runs_on_python_ticker() -> None:
v = AnimatedValue(0.0)
seen: list = []
v.add_listener("x", seen.append)
result = await Animated.timing(v, to=1.0, duration=60, easing=Easing.bezier(0.2, 0.8, 0.2, 1.0))
assert result.finished is True
assert abs(v.value - 1.0) < 1e-9
assert seen == sorted(seen), "an ease-out style bezier should be monotonic"