Files
Marcos Paulo cd43ea488e
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: notescraft-lottie player crate, egui lottie_icon painter, icon_sheet review example, first icon animation set
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 19:42:57 -03:00

88 lines
3.7 KiB
Rust

//! Canonical Bodymovin vector forms and explicit endpoint validation.
use notescraft_lottie::{Animation, Error, Item};
use serde_json::{Value, json};
fn fixture() -> Value {
serde_json::from_str(include_str!("fixtures/stroke.json")).expect("valid fixture")
}
#[test]
fn two_dimensional_layers_accept_neutral_z_components() {
let mut input = fixture();
input["layers"][0]["ks"]["a"]["k"] = json!([0, 0, 0]);
input["layers"][0]["ks"]["p"]["k"] = json!([10, 20, 0]);
input["layers"][0]["ks"]["s"]["k"] = json!([200, 200, 100]);
let animation = Animation::parse(&input.to_string()).expect("2D transform with neutral z");
let frame = animation.frame_at(0.0);
let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") };
assert_eq!(stroke.polylines.first().and_then(|line| line.points.first()), Some(&[14.0, 28.0]));
}
#[test]
fn nonneutral_z_is_rejected_as_three_dimensional() {
let mut input = fixture();
input["layers"][0]["ks"]["p"]["k"] = json!([0, 0, 1]);
assert!(matches!(Animation::parse(&input.to_string()), Err(Error::Unsupported { .. })));
}
#[test]
fn final_shape_transform_applies_to_root_geometry() {
let mut input = fixture();
input["layers"][0]["shapes"].as_array_mut().expect("shapes").push(json!({"ty":"tr", "p":{"a":0,"k":[3,4]}}));
let animation = Animation::parse(&input.to_string()).expect("root transform");
let frame = animation.frame_at(0.0);
let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") };
assert_eq!(stroke.polylines.first().and_then(|line| line.points.first()), Some(&[5.0, 8.0]));
}
#[test]
fn explicit_keyframe_endpoints_are_validated_and_terminal_start_can_be_omitted() {
let mut input = fixture();
input["layers"][0]["ks"]["p"] = json!({"a":1,"k":[{"t":0,"s":[0,0],"e":[10,0]},{"t":10}]});
assert!(Animation::parse(&input.to_string()).is_ok());
input["layers"][0]["ks"]["p"]["k"][1]["s"] = json!([20, 0]);
assert!(Animation::parse(&input.to_string()).is_err());
}
#[test]
fn lock_shackle_vertices_and_tangents_animate_with_fixed_topology() {
let animation = Animation::parse(include_str!("fixtures/animated-shackle.json")).expect("animated shackle");
for (time, endpoint) in [(0.0, [16.0, 12.0]), (5.0, [18.0, 10.0]), (10.0, [20.0, 8.0])] {
let frame = animation.frame_at(time);
let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") };
let line = stroke.polylines.first().expect("shackle contour");
assert_eq!(line.points.first(), Some(&[8.0, 12.0]));
for (&actual, expected) in line.points.last().expect("endpoint").iter().zip(endpoint) {
assert!((actual - expected).abs() < 0.001);
}
assert!(line.points.len() > 4);
}
}
#[test]
fn animated_path_vertex_count_changes_return_error() {
let mut value: Value = serde_json::from_str(include_str!("fixtures/animated-shackle.json")).expect("fixture");
for key in ["v", "i", "o"] {
value["layers"][0]["shapes"][0]["ks"]["k"][1]["s"][0][key].as_array_mut().expect("vertices").pop();
}
assert!(Animation::parse(&value.to_string()).is_err());
}
#[test]
fn every_fixture_byte_can_be_mutated_without_panicking() {
for source in [include_str!("fixtures/stroke.json"), include_str!("fixtures/animated-shackle.json")] {
for index in 0..source.len() {
let mut bytes = source.as_bytes().to_vec();
bytes[index] ^= 0x5a;
let input = String::from_utf8_lossy(&bytes);
let result = std::panic::catch_unwind(|| {
if let Ok(animation) = Animation::parse(&input) {
let _ = animation.frame_at(5.0);
}
});
assert!(result.is_ok(), "byte {index}");
}
}
}