#[allow(unused_imports)]
pub use super::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::{LogicalPosition, LogicalRect, LogicalSize};
#[test]
fn spring_is_a_first_class_interpolation_function() {
let f = AnimationInterpolationFunction::Spring(Spring::SNAPPY);
assert!(f.is_spring());
assert!(!AnimationInterpolationFunction::EaseInOut.is_spring());
assert_eq!(
f.get_curve(),
AnimationInterpolationFunction::EaseInOut.get_curve()
);
for t in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
assert!((ease(f, t) - ease(AnimationInterpolationFunction::EaseInOut, t)).abs() < 1e-6);
}
}
fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
LogicalRect {
origin: LogicalPosition::new(x, y),
size: LogicalSize::new(w, h),
}
}
#[test]
fn flip_inverts_a_pure_translation() {
let f = flip(rect(0.0, 0.0, 10.0, 10.0), rect(100.0, 50.0, 10.0, 10.0));
assert_eq!(f.translate_x, -100.0);
assert_eq!(f.translate_y, -50.0);
assert_eq!(f.scale_x, 1.0);
assert_eq!(f.scale_y, 1.0);
}
#[test]
fn flip_never_scales_a_size_change() {
let f = flip(rect(0.0, 0.0, 50.0, 20.0), rect(0.0, 0.0, 100.0, 40.0));
assert_eq!(f.scale_x, 1.0);
assert_eq!(f.scale_y, 1.0);
assert!(
f.is_identity(),
"same origin, changed size: nothing to animate"
);
}
#[test]
fn flip_of_an_unchanged_rect_is_identity() {
let r = rect(12.0, 34.0, 56.0, 78.0);
assert!(flip(r, r).is_identity());
}
#[test]
fn flip_never_produces_a_non_finite_scale() {
let f = flip(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 0.0, 0.0));
assert!(f.scale_x.is_finite() && f.scale_y.is_finite());
assert_eq!(f.scale_x, 1.0);
assert_eq!(f.scale_y, 1.0);
}
#[test]
fn a_spring_settles_at_its_target() {
let mut c = AnimChannel::spring(0.0, 100.0, Spring::SMOOTH);
for _ in 0..600 {
c.tick(1.0 / 60.0);
if c.is_finished() {
break;
}
}
assert!(c.is_finished(), "spring did not settle within 10s");
assert_eq!(c.current, 100.0);
assert_eq!(c.velocity, 0.0);
}
#[test]
fn a_curve_reaches_its_target_at_the_duration() {
let mut c = AnimChannel::curve(0.0, 10.0, AnimationInterpolationFunction::Linear, 1.0);
for _ in 0..60 {
c.tick(1.0 / 60.0);
}
assert!(
(c.current - 10.0).abs() < 0.01,
"should be at the target within a frame, got {}",
c.current
);
c.tick(1.0 / 60.0);
assert!(
c.is_finished(),
"curve did not finish one frame past its duration"
);
assert_eq!(
c.current, 10.0,
"a finished curve must land exactly on `to`"
);
}
#[test]
fn retarget_preserves_position_and_velocity() {
let mut c = AnimChannel::spring(0.0, 100.0, Spring::SMOOTH);
for _ in 0..10 {
c.tick(1.0 / 60.0);
}
let value_before = c.current;
let velocity_before = c.velocity;
assert!(
value_before > 0.0 && velocity_before > 0.0,
"should be mid-flight"
);
c.retarget(-50.0);
assert_eq!(c.current, value_before, "retarget must not move the value");
assert_eq!(
c.velocity, velocity_before,
"retarget must not discard velocity"
);
assert_eq!(c.from, value_before);
assert_eq!(c.to, -50.0);
assert!(!c.is_finished());
}
#[test]
fn retargeting_to_the_same_target_does_not_restart_the_clock() {
let mut c = AnimChannel::curve(0.0, 10.0, AnimationInterpolationFunction::Linear, 1.0);
c.tick(0.5);
let elapsed = c.elapsed_secs;
c.retarget(10.0);
assert_eq!(
c.elapsed_secs, elapsed,
"a no-op retarget restarted the animation"
);
}
#[test]
fn a_settled_spring_can_be_woken_by_a_retarget() {
let mut c = AnimChannel::spring(0.0, 1.0, Spring::SNAPPY);
for _ in 0..600 {
c.tick(1.0 / 60.0);
if c.is_finished() {
break;
}
}
assert!(c.is_finished());
c.retarget(0.0);
assert!(
!c.is_finished(),
"retarget must un-finish a settled channel"
);
c.tick(1.0 / 60.0);
assert!(
c.current < 1.0,
"woken channel did not move toward the new target"
);
}
#[test]
fn a_huge_frame_gap_cannot_fling_a_spring() {
let mut c = AnimChannel::spring(0.0, 1.0, Spring::SNAPPY);
c.tick(10.0);
assert!(c.current.is_finite());
assert!(c.current.abs() < 100.0, "clamping failed: {}", c.current);
}
#[test]
fn zero_duration_curves_apply_instantly() {
let mut c = AnimChannel::curve(0.0, 42.0, AnimationInterpolationFunction::Ease, 0.0);
c.tick(0.0);
assert!(c.is_finished());
assert_eq!(c.current, 42.0);
}
#[test]
fn easing_curves_are_pinned_at_both_ends() {
for f in [
AnimationInterpolationFunction::Linear,
AnimationInterpolationFunction::Ease,
AnimationInterpolationFunction::EaseIn,
AnimationInterpolationFunction::EaseOut,
AnimationInterpolationFunction::EaseInOut,
] {
assert_eq!(ease(f, 0.0), 0.0, "{f:?} did not start at 0");
assert_eq!(ease(f, 1.0), 1.0, "{f:?} did not end at 1");
assert_eq!(ease(f, -1.0), 0.0);
assert_eq!(ease(f, 2.0), 1.0);
}
}
#[test]
fn ease_in_starts_slower_than_linear_and_ease_out_starts_faster() {
let t = 0.25;
let linear = ease(AnimationInterpolationFunction::Linear, t);
assert!(ease(AnimationInterpolationFunction::EaseIn, t) < linear);
assert!(ease(AnimationInterpolationFunction::EaseOut, t) > linear);
}
#[test]
fn damping_ratio_identifies_the_regime() {
let critical = Spring {
stiffness: 100.0,
damping: 20.0,
mass: 1.0,
};
assert!((critical.damping_ratio() - 1.0).abs() < 1e-5);
assert!(
Spring {
stiffness: 100.0,
damping: 5.0,
mass: 1.0
}
.damping_ratio()
< 1.0
);
assert!(
Spring {
stiffness: 100.0,
damping: 40.0,
mass: 1.0
}
.damping_ratio()
> 1.0
);
}
#[test]
fn a_degenerate_spring_snaps_instead_of_dividing_by_zero() {
let s = Spring {
stiffness: 100.0,
damping: 10.0,
mass: 0.0,
};
let (value, velocity) = s.step(0.0, 5.0, 0.0, 1.0 / 60.0);
assert_eq!(value, 5.0);
assert_eq!(velocity, 0.0);
}
#[test]
fn the_manager_retargets_instead_of_stacking() {
let mut m = AnimationManager::new();
let key = AnimKey(7);
let mode = InterpolationMode::Spring(Spring::SMOOTH);
m.start_or_retarget_move(
key,
flip(rect(0.0, 0.0, 10.0, 10.0), rect(100.0, 0.0, 10.0, 10.0)),
mode,
);
assert_eq!(m.len(), 1);
for _ in 0..10 {
m.tick(1.0 / 60.0);
}
let mid = m.get(key).expect("still animating").current_transform();
m.start_or_retarget_move(
key,
flip(rect(0.0, 0.0, 10.0, 10.0), rect(200.0, 0.0, 10.0, 10.0)),
mode,
);
assert_eq!(m.len(), 1, "retarget created a second animation");
let after = m.get(key).expect("still animating").current_transform();
assert_ne!(
after.translate_x, mid.translate_x,
"retarget did not fold in the new offset"
);
}
#[test]
fn the_manager_reports_and_drops_finished_animations() {
let mut m = AnimationManager::new();
m.start_enter(
AnimKey(1),
(-120.0, 0.0),
InterpolationMode::Curve {
function: AnimationInterpolationFunction::Linear,
duration_secs: 0.1,
},
);
assert_eq!(m.len(), 1);
let mut finished = Vec::new();
for _ in 0..20 {
finished = m.tick(1.0 / 60.0);
if !finished.is_empty() {
break;
}
}
assert_eq!(finished, alloc::vec![AnimKey(1)]);
assert!(m.is_empty(), "finished animation was not dropped");
}
#[test]
fn an_exit_replaces_an_in_flight_move() {
let mut m = AnimationManager::new();
let key = AnimKey(3);
let mode = InterpolationMode::Spring(Spring::SMOOTH);
m.start_or_retarget_move(
key,
flip(rect(0.0, 0.0, 10.0, 10.0), rect(50.0, 0.0, 10.0, 10.0)),
mode,
);
assert_eq!(m.get(key).map(|a| a.class), Some(AnimClass::Move));
m.start_exit(key, (-120.0, 0.0), mode);
assert_eq!(m.get(key).map(|a| a.class), Some(AnimClass::Exit));
assert_eq!(m.len(), 1);
}
#[test]
fn the_anim_key_survives_a_node_id_change() {
use crate::dom::NodeData;
let tree_a = [NodeData::create_div().with_key("hero")];
let tree_b = [
NodeData::create_div().with_key("spacer"),
NodeData::create_div().with_key("hero"),
];
let key_a = AnimKey(calculate_reconciliation_key(&tree_a, &[], NodeId::ZERO));
let key_b = AnimKey(calculate_reconciliation_key(&tree_b, &[], NodeId::new(1)));
assert_eq!(
key_a, key_b,
"the same keyed node got two different AnimKeys"
);
let other = AnimKey(calculate_reconciliation_key(&tree_b, &[], NodeId::ZERO));
assert_ne!(key_a, other);
}
#[test]
fn correspondences_drop_pairs_with_no_geometry() {
use crate::dom::NodeData;
let new_data = [
NodeData::create_div().with_key("a"),
NodeData::create_div().with_key("b"),
];
let moves = [
NodeMove {
old_node_id: NodeId::ZERO,
new_node_id: NodeId::ZERO,
},
NodeMove {
old_node_id: NodeId::new(1),
new_node_id: NodeId::new(1),
},
];
let r = rect(0.0, 0.0, 10.0, 10.0);
let out = correspondences_from_moves(
&moves,
&new_data,
&[],
|id| (id == NodeId::ZERO).then_some(r), |_| Some(rect(5.0, 0.0, 10.0, 10.0)),
);
assert_eq!(
out.len(),
1,
"a node with no previous geometry has nothing to fly from"
);
}
#[test]
fn seed_moves_skips_nodes_that_did_not_move() {
let mut m = AnimationManager::new();
let stayed = rect(0.0, 0.0, 10.0, 10.0);
let moved_first = rect(0.0, 0.0, 10.0, 10.0);
let moved_last = rect(40.0, 0.0, 10.0, 10.0);
let seeded = seed_moves(
&mut m,
[
(AnimKey(1), stayed, stayed),
(AnimKey(2), moved_first, moved_last),
],
InterpolationMode::Spring(Spring::SMOOTH),
);
assert_eq!(seeded, 1, "only the node that moved should animate");
assert!(m.get(AnimKey(1)).is_none());
assert!(m.get(AnimKey(2)).is_some());
}
#[test]
fn seed_moves_retargets_a_key_that_is_already_animating() {
let mut m = AnimationManager::new();
let mode = InterpolationMode::Spring(Spring::SMOOTH);
seed_moves(
&mut m,
[(
AnimKey(9),
rect(0.0, 0.0, 10.0, 10.0),
rect(50.0, 0.0, 10.0, 10.0),
)],
mode,
);
for _ in 0..5 {
m.tick(1.0 / 60.0);
}
let seeded = seed_moves(
&mut m,
[(
AnimKey(9),
rect(0.0, 0.0, 10.0, 10.0),
rect(90.0, 0.0, 10.0, 10.0),
)],
mode,
);
assert_eq!(seeded, 1);
assert_eq!(m.len(), 1, "a second produce stacked a second animation");
}
#[test]
fn an_enter_does_not_clobber_an_animation_already_in_flight() {
let mut m = AnimationManager::new();
let key = AnimKey(5);
let mode = InterpolationMode::Spring(Spring::SMOOTH);
m.start_exit(key, (-120.0, 0.0), mode);
m.start_enter(key, (-120.0, 0.0), mode);
assert_eq!(m.get(key).map(|a| a.class), Some(AnimClass::Exit));
}
}