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)); } }