diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 83774764..f2d94f17 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -647,402 +647,4 @@ pub fn easy_ease(k: &mut Keyframe, ease_in: bool, ease_out: bool) { } #[cfg(test)] -mod tests { - use super::*; - - fn s(x: f64) -> Tick { - Tick::from_seconds_f64(x) - } - - #[test] - fn missing_spatial_keys_have_no_tangents_or_length() { - let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))]; - for i in [1, usize::MAX] { - assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3])); - assert_eq!(spatial_segment_length(&keys, i), 0.0); - } - assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3])); - assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0); - } - - #[test] - fn time_reverse_handles_deserialized_tick_extremes() { - // Tick's public serde representation can contain values beyond from_seconds_f64's cap. - for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] { - let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))]; - time_reverse(&mut keys); - assert_eq!(keys[0].time, Tick(first)); - assert_eq!(keys[0].value, Value::Scalar(2.0)); - assert_eq!(keys[1].time, Tick(last)); - assert_eq!(keys[1].value, Value::Scalar(1.0)); - time_reverse(&mut keys); - assert_eq!(keys[0].value, Value::Scalar(1.0)); - } - } - - #[test] - fn roving_handles_deserialized_tick_extremes() { - let mut keys = vec![ - Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])), - Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])), - Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])), - ]; - for key in &mut keys { - key.spatial_auto = false; - } - keys[1].roving = true; - assert!(retime_roving(&mut keys, true)); - assert_eq!(keys[1].time, Tick::ZERO); - } - - #[test] - fn replacing_key_preserves_spatial_metadata() { - let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0])); - key.roving = true; - key.spatial_continuous = true; - let mut keys = vec![key]; - set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0]))); - assert!(keys[0].roving); - assert!(keys[0].spatial_continuous); - assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0])); - } - - #[test] - fn temporal_ease_matches_live_ae_with_overlapping_influences() { - // Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05. - // AE retains each handle's requested influence even when their sum exceeds 100%. - for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [ - (0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]), - (0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]), - (100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]), - ] { - let mut a = Keyframe::new(s(0.0), Value::Scalar(from)); - let mut b = Keyframe::new(s(1.0), Value::Scalar(to)); - a.out_interp = Interp::Bezier; - b.in_interp = Interp::Bezier; - a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }]; - b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }]; - for (time, expected) in samples { - let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64(); - assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}"); - let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time); - assert!((from + normalized * (to - from) - expected).abs() < 1e-7); - } - } - } - - #[test] - fn maximum_influence_resolves_flat_midpoint_time_curve() { - for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] { - // At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly. - let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt(); - let expected = bez(0.0, 0.0, 1.0, 1.0, u); - let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time); - assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}"); - } - } - - /// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns. - #[test] - fn gradients_coerce_from_plain_json() { - let g = Value::Gradient(Gradient::default()); - let plain = serde_json::json!({"colors": [[0, [0, 0.5, 1, 1]], [1, "#ffff00"]], "opacities": [[0, 1], [1, 0.5]]}); - let Some(Value::Gradient(a)) = g.coerce_json(&plain) else { panic!("object form") }; - assert_eq!(a.colors, vec![(0.0, [0.0, 0.5, 1.0, 1.0]), (1.0, [1.0, 1.0, 0.0, 1.0])]); - assert_eq!(a.opacities, vec![(0.0, 1.0), (1.0, 0.5)]); - let Some(Value::Gradient(b)) = g.coerce_json(&serde_json::json!(["#ff0000", "#00ff00", "#0000ff"])) else { panic!("list form") }; - assert_eq!(b.colors.iter().map(|c| c.0).collect::>(), vec![0.0, 0.5, 1.0]); - assert_eq!(b.opacities.len(), 3); - // The tagged form (what prop.get returns) still round-trips. - assert_eq!(g.coerce_json(&g.to_json()), Some(g.clone())); - assert_eq!(g.coerce_json(&serde_json::json!(5)), None); - } - - /// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies - /// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%). - #[test] - fn gradient_tracks_sample_separately() { - let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] }; - let c = g.color_at(0.5); - assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}"); - assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}"); - assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6); - assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once"); - // A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%. - let mut with_stop = g.clone(); - with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5))); - assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6); - assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look"); - // Outside the stops, and with none: the ends hold, and an empty track is opaque white. - assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]); - assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]); - let empty = Gradient { colors: vec![], opacities: vec![] }; - assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0)); - } - - /// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser - /// rejects) and each stop's RGBA come back exactly. - #[test] - fn typed_gradient_round_trips_one_stop_and_rgba() { - let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] }); - let cur = Value::Gradient(Gradient::default()); - assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone())); - assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours"); - } - - #[test] - fn linear_and_hold() { - let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))]; - assert_eq!(evaluate(&keys, s(1.0), false), Some(Value::Scalar(50.0))); - assert_eq!(evaluate(&keys, s(-1.0), false), Some(Value::Scalar(0.0))); - assert_eq!(evaluate(&keys, s(3.0), false), Some(Value::Scalar(100.0))); - let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(2.0), Value::Scalar(100.0))]; - assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0))); - } - - #[test] - fn hold_key_keeps_its_incoming_segment_linear() { - // Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds - // what comes after it, not the linear motion into it. - let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()]; - assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0))); - let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()]; - let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); - assert!((p[0] - 25.0).abs() < 0.01, "{p:?}"); - } - - #[test] - fn easy_ease_is_symmetric_and_slow_at_ends() { - let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()]; - let mid = evaluate(&keys, s(0.5), false).unwrap().as_f64(); - assert!((mid - 50.0).abs() < 1e-6, "{mid}"); - let early = evaluate(&keys, s(0.1), false).unwrap().as_f64(); - assert!(early < 10.0 && early > 0.0, "{early}"); - let v0 = velocity(&keys, s(0.01), false)[0]; - let vm = velocity(&keys, s(0.5), false)[0]; - assert!(v0 < vm); - } - - #[test] - fn bezier_with_linear_speed_matches_linear() { - let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)); - let mut b = Keyframe::new(s(2.0), Value::Scalar(100.0)); - a.out_interp = Interp::Bezier; - b.in_interp = Interp::Bezier; - a.out_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }]; - b.in_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }]; - let keys = vec![a, b]; - for i in 0..=20 { - let t = i as f64 * 0.1; - let v = evaluate(&keys, s(t), false).unwrap().as_f64(); - assert!((v - 50.0 * t).abs() < 1e-6, "{t} {v}"); - } - } - - #[test] - fn spatial_linear_constant_speed() { - let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))]; - let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); - assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}"); - } - - /// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is - /// flat, not a sawtooth from a piecewise-linear arc-length table. - #[test] - fn spatial_curved_path_has_smooth_constant_speed() { - let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])); - let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])); - a.spatial_auto = false; - b.spatial_auto = false; - a.spatial_out = [50.0, -300.0, 0.0]; - b.spatial_in = [80.0, 250.0, 0.0]; - let keys = vec![a, b]; - let len = spatial_segment_length(&keys, 0); - let speeds: Vec = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect(); - for v in &speeds { - assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0); - } - } - - #[test] - fn spatial_auto_bezier_passes_through_keys() { - let keys = vec![ - Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), - Keyframe::new(s(1.0), Value::Vec2([100.0, 100.0])), - Keyframe::new(s(2.0), Value::Vec2([200.0, 0.0])), - ]; - let p = evaluate(&keys, s(1.0), true).unwrap().as_vec2(); - assert_eq!(p, [100.0, 100.0]); - // Curved: the midpoint of the first segment lies off the straight line. - let m = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); - assert!((m[0] - m[1]).abs() > 1.0, "{m:?}"); - } - - #[test] - fn straight_auto_bezier_path_is_exact() { - // Auto-Bezier ends now have handles along the line; the chord is still hit exactly. - let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))]; - assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0])); - } - - /// #880: a motion path whose arc length overflows (huge coordinates, as the Position field - /// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app. - #[test] - fn overflowing_or_nan_motion_paths_evaluate() { - let paths = [ - [[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]], - [[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]], - [[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]], - ]; - for path in paths { - let mut keys: Vec = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect(); - for eased in [false, true] { - if eased { - keys = keys.into_iter().map(Keyframe::eased).collect(); - } - for f in 0..=40 { - let t = s(f as f64 * 0.1); - assert!(evaluate(&keys, t, true).is_some()); - let _ = speed(&keys, t, true); - } - } - } - } - - #[test] - fn spatial_auto_bezier_tangents_follow_after_effects() { - // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each - // auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror, - // whatever the key times. End keys use their one neighbour: (neighbour - key) / 6. - let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]]; - let mut keys: Vec = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect(); - let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0]; - let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; - let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); - let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])]; - for (i, out) in want.into_iter().enumerate() { - let (tin, tout) = spatial_tangents(&keys, i); - assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}"); - } - // Moving a key in time leaves every tangent as it was. - keys[1].time = s(1.2); - assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2]))); - // A key between two coincident neighbours, and a lone key, have none. - let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::>(); - assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3])); - assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); - } - - #[test] - fn auto_spatial_holds_between_identical_keys() { - let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])]; - for eased in [false, true] { - let keys: Vec = path - .iter() - .map(|&(t, v)| { - let k = Keyframe::new(s(t), Value::Vec2(v)); - if eased { k.eased() } else { k } - }) - .collect(); - assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]); - assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]); - for t in [0.7, 1.0, 2.5, 4.0, 4.3] { - let p = evaluate(&keys, s(t), true).unwrap().as_vec3(); - assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}"); - } - } - } - - #[test] - fn set_key_replaces_same_time() { - let mut keys = vec![]; - set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(1.0)).eased()); - set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(0.0))); - set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(5.0))); - assert_eq!(keys.len(), 2); - assert_eq!(keys[1].value, Value::Scalar(5.0)); - assert_eq!(keys[1].in_interp, Interp::Bezier); - } - - #[test] - fn colors_and_paths_interpolate() { - let keys = vec![Keyframe::new(s(0.0), Value::Color([0.0, 0.0, 0.0, 1.0])), Keyframe::new(s(1.0), Value::Color([1.0, 0.5, 0.0, 1.0]))]; - assert_eq!(evaluate(&keys, s(0.5), false).unwrap().as_color(), [0.5, 0.25, 0.0, 1.0]); - let a = ShapePath::rect([0.0, 0.0], 10.0, 10.0); - let b = ShapePath::rect([0.0, 0.0], 20.0, 20.0); - let keys = vec![Keyframe::new(s(0.0), Value::Path(a)), Keyframe::new(s(1.0), Value::Path(b))]; - let v = evaluate(&keys, s(0.5), false).unwrap(); - assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]); - } - - #[test] - fn roving_keys_get_constant_speed_times() { - let mut keys = vec![ - Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), - Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])), - Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])), - ]; - for k in &mut keys { - k.spatial_auto = false; - } - keys[1].roving = true; - assert!(retime_roving(&mut keys, true)); - // 300 of 400 px → 75 % of the 2 s span. - assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds()); - let v0 = speed(&keys, s(0.5), true); - let v1 = speed(&keys, s(1.8), true); - assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}"); - // First/last keys can't rove. - keys[0].roving = true; - retime_roving(&mut keys, true); - assert!(!keys[0].roving); - } - - #[test] - fn roving_keys_never_share_a_time_with_a_fixed_key() { - // Zero-length last segment (issue #532) and zero-length first segment. - for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] { - let mut keys: Vec = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect(); - for k in &mut keys { - k.spatial_auto = false; - } - keys[1].roving = true; - keys[2].roving = true; - retime_roving(&mut keys, true); - assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::>()); - } - } - - #[test] - fn side_ease_reports_linear_and_eased_sides() { - let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; - let out = side_ease(&keys, 0, 0, false, true).unwrap(); - assert!((out.speed - 50.0).abs() < 1e-9); - let inn = side_ease(&keys, 1, 0, false, false).unwrap(); - assert_eq!(inn, Ease::EASY); - assert!(side_ease(&keys, 0, 0, false, false).is_none()); - } - - #[test] - fn time_reverse_mirrors_keys() { - let mut keys = - vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))]; - keys[0].out_interp = Interp::Hold; - time_reverse(&mut keys); - assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::>(), vec![1, 3, 4]); - assert_eq!(keys[0].value, Value::Scalar(10.0)); - assert_eq!(keys[2].in_interp, Interp::Hold); - } - - proptest::proptest! { - #[test] - fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) { - let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(); - let mut b = Keyframe::new(s(1.0), Value::Scalar(10.0)).eased(); - a.out_ease = vec![Ease { speed: 0.0, influence: i0 }]; - b.in_ease = vec![Ease { speed: 0.0, influence: i1 }]; - let v = evaluate(&[a, b], s(t), false).unwrap().as_f64(); - proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); - } - } -} +mod tests; diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs new file mode 100644 index 00000000..0a31cfc1 --- /dev/null +++ b/crates/keyframe/src/tests.rs @@ -0,0 +1,397 @@ +use super::*; + +fn s(x: f64) -> Tick { + Tick::from_seconds_f64(x) +} + +#[test] +fn missing_spatial_keys_have_no_tangents_or_length() { + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))]; + for i in [1, usize::MAX] { + assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_segment_length(&keys, i), 0.0); + } + assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0); +} + +#[test] +fn time_reverse_handles_deserialized_tick_extremes() { + // Tick's public serde representation can contain values beyond from_seconds_f64's cap. + for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] { + let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))]; + time_reverse(&mut keys); + assert_eq!(keys[0].time, Tick(first)); + assert_eq!(keys[0].value, Value::Scalar(2.0)); + assert_eq!(keys[1].time, Tick(last)); + assert_eq!(keys[1].value, Value::Scalar(1.0)); + time_reverse(&mut keys); + assert_eq!(keys[0].value, Value::Scalar(1.0)); + } +} + +#[test] +fn roving_handles_deserialized_tick_extremes() { + let mut keys = vec![ + Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])), + Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])), + Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])), + ]; + for key in &mut keys { + key.spatial_auto = false; + } + keys[1].roving = true; + assert!(retime_roving(&mut keys, true)); + assert_eq!(keys[1].time, Tick::ZERO); +} + +#[test] +fn replacing_key_preserves_spatial_metadata() { + let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0])); + key.roving = true; + key.spatial_continuous = true; + let mut keys = vec![key]; + set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0]))); + assert!(keys[0].roving); + assert!(keys[0].spatial_continuous); + assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0])); +} + +#[test] +fn temporal_ease_matches_live_ae_with_overlapping_influences() { + // Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05. + // AE retains each handle's requested influence even when their sum exceeds 100%. + for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [ + (0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]), + (0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]), + (100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]), + ] { + let mut a = Keyframe::new(s(0.0), Value::Scalar(from)); + let mut b = Keyframe::new(s(1.0), Value::Scalar(to)); + a.out_interp = Interp::Bezier; + b.in_interp = Interp::Bezier; + a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }]; + b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }]; + for (time, expected) in samples { + let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64(); + assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}"); + let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time); + assert!((from + normalized * (to - from) - expected).abs() < 1e-7); + } + } +} + +#[test] +fn maximum_influence_resolves_flat_midpoint_time_curve() { + for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] { + // At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly. + let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt(); + let expected = bez(0.0, 0.0, 1.0, 1.0, u); + let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time); + assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}"); + } +} + +/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns. +#[test] +fn gradients_coerce_from_plain_json() { + let g = Value::Gradient(Gradient::default()); + let plain = serde_json::json!({"colors": [[0, [0, 0.5, 1, 1]], [1, "#ffff00"]], "opacities": [[0, 1], [1, 0.5]]}); + let Some(Value::Gradient(a)) = g.coerce_json(&plain) else { panic!("object form") }; + assert_eq!(a.colors, vec![(0.0, [0.0, 0.5, 1.0, 1.0]), (1.0, [1.0, 1.0, 0.0, 1.0])]); + assert_eq!(a.opacities, vec![(0.0, 1.0), (1.0, 0.5)]); + let Some(Value::Gradient(b)) = g.coerce_json(&serde_json::json!(["#ff0000", "#00ff00", "#0000ff"])) else { panic!("list form") }; + assert_eq!(b.colors.iter().map(|c| c.0).collect::>(), vec![0.0, 0.5, 1.0]); + assert_eq!(b.opacities.len(), 3); + // The tagged form (what prop.get returns) still round-trips. + assert_eq!(g.coerce_json(&g.to_json()), Some(g.clone())); + assert_eq!(g.coerce_json(&serde_json::json!(5)), None); +} + +/// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies +/// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%). +#[test] +fn gradient_tracks_sample_separately() { + let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] }; + let c = g.color_at(0.5); + assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}"); + assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}"); + assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6); + assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once"); + // A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%. + let mut with_stop = g.clone(); + with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5))); + assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6); + assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look"); + // Outside the stops, and with none: the ends hold, and an empty track is opaque white. + assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]); + assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]); + let empty = Gradient { colors: vec![], opacities: vec![] }; + assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0)); +} + +/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser +/// rejects) and each stop's RGBA come back exactly. +#[test] +fn typed_gradient_round_trips_one_stop_and_rgba() { + let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] }); + let cur = Value::Gradient(Gradient::default()); + assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone())); + assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours"); +} + +#[test] +fn linear_and_hold() { + let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))]; + assert_eq!(evaluate(&keys, s(1.0), false), Some(Value::Scalar(50.0))); + assert_eq!(evaluate(&keys, s(-1.0), false), Some(Value::Scalar(0.0))); + assert_eq!(evaluate(&keys, s(3.0), false), Some(Value::Scalar(100.0))); + let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(2.0), Value::Scalar(100.0))]; + assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0))); +} + +#[test] +fn hold_key_keeps_its_incoming_segment_linear() { + // Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds + // what comes after it, not the linear motion into it. + let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()]; + assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0))); + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()]; + let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); + assert!((p[0] - 25.0).abs() < 0.01, "{p:?}"); +} + +#[test] +fn easy_ease_is_symmetric_and_slow_at_ends() { + let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()]; + let mid = evaluate(&keys, s(0.5), false).unwrap().as_f64(); + assert!((mid - 50.0).abs() < 1e-6, "{mid}"); + let early = evaluate(&keys, s(0.1), false).unwrap().as_f64(); + assert!(early < 10.0 && early > 0.0, "{early}"); + let v0 = velocity(&keys, s(0.01), false)[0]; + let vm = velocity(&keys, s(0.5), false)[0]; + assert!(v0 < vm); +} + +#[test] +fn bezier_with_linear_speed_matches_linear() { + let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)); + let mut b = Keyframe::new(s(2.0), Value::Scalar(100.0)); + a.out_interp = Interp::Bezier; + b.in_interp = Interp::Bezier; + a.out_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }]; + b.in_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }]; + let keys = vec![a, b]; + for i in 0..=20 { + let t = i as f64 * 0.1; + let v = evaluate(&keys, s(t), false).unwrap().as_f64(); + assert!((v - 50.0 * t).abs() < 1e-6, "{t} {v}"); + } +} + +#[test] +fn spatial_linear_constant_speed() { + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))]; + let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); + assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}"); +} + +/// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is +/// flat, not a sawtooth from a piecewise-linear arc-length table. +#[test] +fn spatial_curved_path_has_smooth_constant_speed() { + let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])); + let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])); + a.spatial_auto = false; + b.spatial_auto = false; + a.spatial_out = [50.0, -300.0, 0.0]; + b.spatial_in = [80.0, 250.0, 0.0]; + let keys = vec![a, b]; + let len = spatial_segment_length(&keys, 0); + let speeds: Vec = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect(); + for v in &speeds { + assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0); + } +} + +#[test] +fn spatial_auto_bezier_passes_through_keys() { + let keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(1.0), Value::Vec2([100.0, 100.0])), + Keyframe::new(s(2.0), Value::Vec2([200.0, 0.0])), + ]; + let p = evaluate(&keys, s(1.0), true).unwrap().as_vec2(); + assert_eq!(p, [100.0, 100.0]); + // Curved: the midpoint of the first segment lies off the straight line. + let m = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); + assert!((m[0] - m[1]).abs() > 1.0, "{m:?}"); +} + +#[test] +fn straight_auto_bezier_path_is_exact() { + // Auto-Bezier ends now have handles along the line; the chord is still hit exactly. + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))]; + assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0])); +} + +/// #880: a motion path whose arc length overflows (huge coordinates, as the Position field +/// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app. +#[test] +fn overflowing_or_nan_motion_paths_evaluate() { + let paths = [ + [[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]], + [[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]], + [[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]], + ]; + for path in paths { + let mut keys: Vec = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect(); + for eased in [false, true] { + if eased { + keys = keys.into_iter().map(Keyframe::eased).collect(); + } + for f in 0..=40 { + let t = s(f as f64 * 0.1); + assert!(evaluate(&keys, t, true).is_some()); + let _ = speed(&keys, t, true); + } + } + } +} + +#[test] +fn spatial_auto_bezier_tangents_follow_after_effects() { + // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each + // auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror, + // whatever the key times. End keys use their one neighbour: (neighbour - key) / 6. + let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]]; + let mut keys: Vec = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect(); + let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0]; + let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])]; + for (i, out) in want.into_iter().enumerate() { + let (tin, tout) = spatial_tangents(&keys, i); + assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}"); + } + // Moving a key in time leaves every tangent as it was. + keys[1].time = s(1.2); + assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2]))); + // A key between two coincident neighbours, and a lone key, have none. + let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::>(); + assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); +} + +#[test] +fn auto_spatial_holds_between_identical_keys() { + let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])]; + for eased in [false, true] { + let keys: Vec = path + .iter() + .map(|&(t, v)| { + let k = Keyframe::new(s(t), Value::Vec2(v)); + if eased { k.eased() } else { k } + }) + .collect(); + assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]); + assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]); + for t in [0.7, 1.0, 2.5, 4.0, 4.3] { + let p = evaluate(&keys, s(t), true).unwrap().as_vec3(); + assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}"); + } + } +} + +#[test] +fn set_key_replaces_same_time() { + let mut keys = vec![]; + set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(1.0)).eased()); + set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(0.0))); + set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(5.0))); + assert_eq!(keys.len(), 2); + assert_eq!(keys[1].value, Value::Scalar(5.0)); + assert_eq!(keys[1].in_interp, Interp::Bezier); +} + +#[test] +fn colors_and_paths_interpolate() { + let keys = vec![Keyframe::new(s(0.0), Value::Color([0.0, 0.0, 0.0, 1.0])), Keyframe::new(s(1.0), Value::Color([1.0, 0.5, 0.0, 1.0]))]; + assert_eq!(evaluate(&keys, s(0.5), false).unwrap().as_color(), [0.5, 0.25, 0.0, 1.0]); + let a = ShapePath::rect([0.0, 0.0], 10.0, 10.0); + let b = ShapePath::rect([0.0, 0.0], 20.0, 20.0); + let keys = vec![Keyframe::new(s(0.0), Value::Path(a)), Keyframe::new(s(1.0), Value::Path(b))]; + let v = evaluate(&keys, s(0.5), false).unwrap(); + assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]); +} + +#[test] +fn roving_keys_get_constant_speed_times() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])), + Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])), + ]; + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + assert!(retime_roving(&mut keys, true)); + // 300 of 400 px → 75 % of the 2 s span. + assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds()); + let v0 = speed(&keys, s(0.5), true); + let v1 = speed(&keys, s(1.8), true); + assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}"); + // First/last keys can't rove. + keys[0].roving = true; + retime_roving(&mut keys, true); + assert!(!keys[0].roving); +} + +#[test] +fn roving_keys_never_share_a_time_with_a_fixed_key() { + // Zero-length last segment (issue #532) and zero-length first segment. + for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] { + let mut keys: Vec = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect(); + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + keys[2].roving = true; + retime_roving(&mut keys, true); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::>()); + } +} + +#[test] +fn side_ease_reports_linear_and_eased_sides() { + let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; + let out = side_ease(&keys, 0, 0, false, true).unwrap(); + assert!((out.speed - 50.0).abs() < 1e-9); + let inn = side_ease(&keys, 1, 0, false, false).unwrap(); + assert_eq!(inn, Ease::EASY); + assert!(side_ease(&keys, 0, 0, false, false).is_none()); +} + +#[test] +fn time_reverse_mirrors_keys() { + let mut keys = + vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))]; + keys[0].out_interp = Interp::Hold; + time_reverse(&mut keys); + assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::>(), vec![1, 3, 4]); + assert_eq!(keys[0].value, Value::Scalar(10.0)); + assert_eq!(keys[2].in_interp, Interp::Hold); +} + +proptest::proptest! { + #[test] + fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) { + let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(); + let mut b = Keyframe::new(s(1.0), Value::Scalar(10.0)).eased(); + a.out_ease = vec![Ease { speed: 0.0, influence: i0 }]; + b.in_ease = vec![Ease { speed: 0.0, influence: i1 }]; + let v = evaluate(&[a, b], s(t), false).unwrap().as_f64(); + proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); + } +}