Files
effectcraft/crates/engine/src/tests_tail.rs
T
Claude c8aaac824d shortcuts: Go to Work Area Start / End default to Shift+Home / Shift+End, as in After Effects
Split out of 7782ab01 (shapes: Reverse Path Direction …; work area keys) for upstream, where it
belongs with the commands it binds.

(cherry picked from commit 7782ab015a01613c1685cb6dda48d48c3b1db67b)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F2dXDtF1ivCLP4tQwFveQ4

(cherry picked from commit b58cfb7434e147535e66582718b8856bd3ce1230)
(cherry picked from commit ff4876054f3cbf9388954602e8fe7288d136c255)

(cherry picked from commit 79d083a8c2a0bfa4663445257149a0d0ce491987)
2026-10-12 19:21:40 +01:00

449 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M13.5 long-tail parity: stroke taper/wave/dashes, and the other small gaps (commands, undo,
//! serde).
use serde_json::{Value, json};
use crate::Session;
use effectcraft_project::{LayerId, Project};
pub(crate) fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Tail", "width": w, "height": h, "frameRate": 30, "duration": 2})).unwrap();
s
}
pub(crate) fn roundtrip(s: &Session) -> Project {
Project::from_json(&s.project.to_json()).unwrap()
}
/// A horizontal open path 160 px long across a 200×100 comp, 10 px red stroke and no fill.
fn line_layer(s: &mut Session) -> (u64, u64) {
let r = s.execute("shape.newPath", json!({"vertices": [[20, 50], [180, 50]], "fill": false, "stroke": [1, 0, 0], "strokeWidth": 10})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
let stroke = l.props.find_group(r["group"].as_u64().unwrap()).unwrap().sub("contents").unwrap().sub("stroke").unwrap().uid;
(lid, stroke)
}
fn column(s: &Session, x: u32) -> f32 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
(0..img.height).map(|y| img.data[(y * img.width + x) as usize][3]).sum()
}
#[test]
fn stroke_taper_wave_commands_render_undo_and_serde() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
assert!((column(&s, 30) - 10.0).abs() < 0.6);
let r = s.execute("shape.stroke.taper", json!({"layer": lid, "startLength": 40, "startWidth": 0})).unwrap();
assert_eq!(r["stroke"], json!(stroke));
assert!((column(&s, 30) - 2.5).abs() < 0.8, "tapered start: {}", column(&s, 30));
// Percent units.
s.execute("shape.stroke.taper", json!({"layer": lid, "units": "percent", "startLength": 25})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8, "25 % of 160 px = 40 px: {}", column(&s, 30));
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!((column(&s, 30) - 10.0).abs() < 0.6, "undo restores the plain stroke");
s.execute("edit.redo", json!({})).unwrap();
assert!((column(&s, 30) - 2.5).abs() < 0.8);
// Wave in cycles.
s.execute("shape.stroke.wave", json!({"layer": lid, "amount": 100, "units": "cycles", "cycles": 4})).unwrap();
assert!(column(&s, 120) < 0.6, "trough at 100 px (2.5 cycles): {}", column(&s, 120));
// Values are ordinary animatable properties that survive save/load.
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
let g = l.props.find_group(stroke).unwrap();
assert_eq!(g.prop("wave/cycles").unwrap().value.as_f64(), 4.0);
assert_eq!(g.prop("taper/startLength").unwrap().value.as_f64(), 40.0);
assert!(s.execute("shape.stroke.wave", json!({"layer": lid, "units": "furlongs"})).is_err());
}
#[test]
fn dashes_plus_minus_buttons() {
let mut s = comp(200, 100);
let (lid, stroke) = line_layer(&mut s);
let add = |s: &mut Session| s.execute("shape.dashes.add", json!({"layer": lid, "prop": stroke})).unwrap()["added"].as_str().unwrap().to_string();
assert_eq!(add(&mut s), "dash");
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash"), "value": 20})).unwrap();
assert_eq!(add(&mut s), "dash2");
assert_eq!(add(&mut s), "dash3");
assert!(s.execute("shape.dashes.add", json!({"layer": lid})).is_err(), "three pairs at most");
let names = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
l.props.find_group(stroke).unwrap().sub("dashes").unwrap().children.iter().map(|n| n.match_id().to_string()).collect::<Vec<_>>()
};
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "dash3", "gap3", "offset"]);
// Dash 20, Gap 20 (= dash), Dash 2 20, Gap 2 20…: set Dash 2 to 5.
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap"), "value": 10})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "dash2"), "value": 5})).unwrap();
s.execute("prop.set", json!({"layer": lid, "prop": dash_uid(&s, lid, stroke, "gap2"), "value": 15})).unwrap();
s.execute("shape.dashes.remove", json!({"layer": lid})).unwrap();
assert_eq!(names(&s), ["dash", "gap", "dash2", "gap2", "offset"]);
// Pattern 20 on, 10 off, 5 on, 15 off from x = 20 (butt caps).
assert!(column(&s, 30) > 9.0 && column(&s, 45) < 0.1 && column(&s, 52) > 9.0 && column(&s, 60) < 0.1 && column(&s, 80) > 9.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(names(&s).len(), 7);
let back = roundtrip(&s);
let l = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(lid)).unwrap();
assert_eq!(l.props.find_group(stroke).unwrap().prop("dashes/dash2").unwrap().value.as_f64(), 5.0);
}
fn dash_uid(s: &Session, lid: u64, stroke: u64, m: &str) -> Value {
let l = s.active_comp().unwrap().layer(LayerId(lid)).unwrap();
json!(l.props.find_group(stroke).unwrap().sub("dashes").unwrap().get(m).unwrap().uid)
}
/// A white 200×200 solid in a 200×200 comp with a 100×100 rectangular mask (50…150).
fn masked_solid(s: &mut Session) -> (u64, u64) {
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[50, 50], [150, 50], [150, 150], [50, 150]], "closed": true})).unwrap();
(l, r["mask"].as_u64().unwrap())
}
fn render_at(s: &Session, t: f64) -> effectcraft_raster::Image {
let cid = s.active_comp_id().unwrap();
s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), Default::default())
}
fn alpha_at(s: &Session, x: u32, y: u32, t: f64) -> f32 {
let img = render_at(s, t);
img.data[(y * img.width + x) as usize][3]
}
#[test]
fn variable_mask_feather_points() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("layer.mask.featherFalloff", json!({"layer": l, "mode": "linear"})).unwrap();
assert!(alpha_at(&s, 100, 45, 0.0) < 0.01);
// Mask Feather tool: click on the path and drag out above the top edge → an outer point of 20 px.
let r = s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 30]})).unwrap();
assert_eq!(r["index"], json!(0));
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
let p0 = &pts["points"][0];
assert_eq!(p0["segment"], json!(0));
assert!((p0["t"].as_f64().unwrap() - 0.5).abs() < 1e-3 && (p0["radius"].as_f64().unwrap() - 20.0).abs() < 1e-3, "{pts}");
let soft = alpha_at(&s, 100, 39, 0.0);
assert!((soft - 0.475).abs() < 0.06, "{soft}");
// An inner point dragged inside the bottom edge.
s.execute("mask.featherPoint.add", json!({"layer": l, "mask": m, "point": [100, 130], "tension": 100})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert!((pts["points"][1]["radius"].as_f64().unwrap() + 20.0).abs() < 1e-3, "{pts}");
assert!((alpha_at(&s, 100, 140, 0.0) - 0.475).abs() < 0.08, "{}", alpha_at(&s, 100, 140, 0.0));
assert!(alpha_at(&s, 100, 152, 0.0) < 0.01);
// Drag the radius; undo; redo.
s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 40})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) > 0.7);
s.execute("edit.undo", json!({})).unwrap();
assert!((alpha_at(&s, 100, 39, 0.0) - soft).abs() < 0.08);
s.execute("edit.redo", json!({})).unwrap();
// Inserting a vertex keeps the points where they were on the path.
let before = alpha_at(&s, 100, 39, 0.0);
s.execute("mask.insertVertex", json!({"layer": l, "mask": m, "segment": 0, "t": 0.25})).unwrap();
let pts = s.execute("mask.featherPoint.list", json!({"layer": l, "mask": m})).unwrap();
assert_eq!(pts["points"][0]["segment"], json!(1));
assert!((pts["points"][0]["t"].as_f64().unwrap() - 1.0 / 3.0).abs() < 1e-6);
assert!((alpha_at(&s, 100, 39, 0.0) - before).abs() < 0.02);
// Serde: the points travel in the Mask Path value.
let back = roundtrip(&s);
let lay = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(l)).unwrap();
let effectcraft_keyframe::Value::Path(sp) = &lay.props.find_group(m).unwrap().get("path").unwrap().value else { panic!() };
assert_eq!(sp.feather.len(), 2);
s.execute("mask.featherPoint.remove", json!({"layer": l, "mask": m, "all": true})).unwrap();
assert!(alpha_at(&s, 100, 39, 0.0) < 0.01);
assert!(s.execute("mask.featherPoint.set", json!({"layer": l, "mask": m, "index": 0, "radius": 1})).is_err());
}
#[test]
fn mask_motion_blur_ramps_across_the_motion() {
let mut s = comp(200, 200);
let (l, m) = masked_solid(&mut s);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "on"})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "masks/#1/path", "time": 0.0})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
// 30 px in 30 frames: 1 px per frame; with a 360° shutter at frame 15 the right edge sweeps
// 165…166, so pixel 165 is half covered.
let verts: Vec<Value> = (0..4).map(|i| json!({"layer": l, "mask": m, "index": i})).collect();
s.execute("mask.moveVertices", json!({"vertices": verts, "delta": [30, 0]})).unwrap();
let a = alpha_at(&s, 165, 100, 0.5);
assert!(a > 0.3 && a < 0.7, "{a}");
s.execute("layer.mask.motionBlur", json!({"layer": l, "mode": "off"})).unwrap();
let hard = alpha_at(&s, 165, 100, 0.5);
assert!(!(0.3..0.7).contains(&hard), "{hard}");
}
#[test]
fn adaptive_sample_limit_drives_layer_samples() {
assert_eq!(effectcraft_render::adaptive_samples(0.5, 16, 128), 16);
assert_eq!(effectcraft_render::adaptive_samples(100.0, 16, 128), 100);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 128), 128);
assert_eq!(effectcraft_render::adaptive_samples(400.0, 16, 9999), 256);
let mut s = comp(400, 100);
s.execute("comp.settings", json!({"shutterAngle": 360, "shutterPhase": 0})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
// 100 px per frame (keys 3 frames apart, 300 px).
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [50, 50]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.1, "value": [350, 50]})).unwrap();
let max_step = |s: &Session| {
let img = render_at(s, 1.0 / 30.0);
let row: Vec<f32> = (0..img.width).map(|x| img.data[(50 * img.width + x) as usize][3]).collect();
row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max)
};
s.execute("comp.settings", json!({"adaptiveSampleLimit": 16})).unwrap();
let coarse = max_step(&s);
s.execute("comp.settings", json!({"adaptiveSampleLimit": 256})).unwrap();
let fine = max_step(&s);
assert!(coarse > 0.05, "16 samples over 100 px leave visible steps: {coarse}");
assert!(fine < 0.03, "≈100 samples give a smooth ramp: {fine}");
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 256);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().motion_blur_adaptive_limit, 16);
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 16);
// Older projects without the field read the After Effects default.
fn strip(v: &mut Value) {
match v {
Value::Object(o) => {
o.remove("motion_blur_adaptive_limit");
o.values_mut().for_each(strip);
}
Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut v: Value = serde_json::from_str(&s.project.to_json()).unwrap();
strip(&mut v);
let json = v.to_string();
assert!(!json.contains("motion_blur_adaptive_limit"));
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
}
#[test]
fn keyframe_label_groups() {
let mut s = comp(200, 100);
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
for l in [a, b] {
for t in [0.0, 0.5, 1.0] {
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap();
}
}
let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
let (ua, ub) = (uid(&s, a), uid(&s, b));
// Label a's first key and b's last key Blue.
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap();
s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap();
s.execute("keys.setLabel", json!({"label": "Green"})).unwrap();
// On selected layers: from a's first key, only a's Blue key.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1));
// On all layers: both Blue keys.
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2));
assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6));
// Visible keyframes: only the revealed properties count.
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1));
// Unlabelled keys group together too (label None).
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap();
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3));
// Undo, serde.
let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label;
assert_eq!(lab(&s), 9);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(lab(&s), 0);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9);
assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err());
}
#[test]
fn tate_chu_yoko_via_set_text_with_undo_and_serde() {
let mut s = comp(400, 400);
let t = s.execute("layer.newText", json!({"text": "A12B", "size": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setText", json!({"layer": t, "vertical": true})).unwrap();
s.execute("layer.setText", json!({"layer": t, "range": [1, 3], "tateChuYoko": true})).unwrap();
let doc = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert!(doc.style_at(1).tate_chu_yoko && doc.style_at(2).tate_chu_yoko && !doc.style_at(0).tate_chu_yoko);
let lay = effectcraft_text::layout_doc(&doc);
assert!((lay.glyphs[1].origin.y - lay.glyphs[2].origin.y).abs() < 1e-6, "the digits share a row");
let back = roundtrip(&s);
let effectcraft_keyframe::Value::Text(d) =
&back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(t)).unwrap().props.prop("text/sourceText").unwrap().value
else {
panic!()
};
assert!(d.style_at(1).tate_chu_yoko);
s.execute("edit.undo", json!({})).unwrap();
assert!(!crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(1).tate_chu_yoko);
s.execute("layer.setText", json!({"layer": t, "verticalRomanUpright": true})).unwrap();
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().vertical_roman_upright);
}
#[test]
fn variable_font_axes_animator() {
let mut s = comp(400, 200);
let t = s.execute("layer.newText", json!({"text": "HH", "size": 60})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [t]})).unwrap();
assert!(s.is_enabled("text.animatorFontAxes"));
// Inter (bundled) is static.
assert_eq!(s.execute("text.animatorFontAxes", json!({})).unwrap()["axes"], json!([]));
assert!(s.execute("text.animatorFontAxes", json!({"axis": "wght"})).is_err());
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
eprintln!("no variable font installed: animation check skipped");
return;
};
let info = effectcraft_text::fonts::face(face).info.clone();
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
let axis = axes.iter().find(|a| a.max > a.min).unwrap().clone();
let listed = s.execute("text.animatorFontAxes", json!({})).unwrap();
if listed["axes"].as_array().is_none_or(|a| a.is_empty()) {
eprintln!("the variable face isn't reachable by family/style: skipped");
return;
}
let tag = axis.tag.clone();
let r = s.execute("text.animatorFontAxes", json!({"axis": tag})).unwrap();
let anim = r["animator"].as_u64().unwrap();
let area = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
let l = comp.layer(LayerId(t)).unwrap();
effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum::<f64>()
};
// The distance between the two H's left edges: the H advance.
let gap = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
let l = comp.layer(LayerId(t)).unwrap();
let x0: Vec<f64> = effectcraft_render::text::glyph_paths(&ctx, l).iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x0).collect();
x0[1] - x0[0]
};
let before = area(&s);
let gap_before = gap(&s);
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap().clone();
let pg = l.props.find_group(anim).unwrap().sub("properties").unwrap().props().next().unwrap().uid;
let target = if axis.max - axis.default >= axis.default - axis.min { axis.max } else { axis.min };
let range = (target - axis.default) as f64;
s.execute("prop.set", json!({"layer": t, "prop": pg, "value": range})).unwrap();
let after = area(&s);
assert!((after - before).abs() > 1.0, "{} axis moved the outline: {before} → {after}", axis.tag);
// Advances follow the axis too (the text is re-spaced, not only redrawn).
let a = axes.iter().find(|a| a.tag == tag).unwrap();
let f = effectcraft_text::fonts::face(face);
let gid = f.glyph('H').unwrap();
let upem = f.units_per_em() as f64;
let units = |v: f32| effectcraft_text::variable::advance_units_at(face, gid, &[(a.tag.clone(), v)]).unwrap() as f64;
let want = (units((a.default + range as f32).clamp(a.min, a.max)) - units(a.default)) * 60.0 / upem;
let moved = gap(&s) - gap_before;
assert!((moved - want).abs() < 0.5, "{} axis: advance change {moved}, expected {want}", a.tag);
s.execute("edit.undo", json!({})).unwrap();
assert!((area(&s) - before).abs() < 1e-6);
let _ = info;
}
#[test]
fn variable_font_axes_in_the_character_style() {
let mut s = comp(600, 200);
let t = s.execute("layer.newText", json!({"text": "Hamburg", "size": 60})).unwrap()["layer"].as_u64().unwrap();
// The attribute round-trips and is undoable even for static fonts (it is kept, unused).
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert_eq!(d.style_at(0).variations, vec![("wght".to_string(), 650.0)]);
assert_eq!(effectcraft_keyframe::text_doc::char_style_json(&d.style_at(0))["variations"], json!({"wght": 650.0}));
s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "variations": {"wght": null}})).unwrap();
let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap();
assert!(d.style_at(0).variations.is_empty() && !d.style_at(5).variations.is_empty());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(0).variations.is_empty());
assert!(s.execute("layer.setText", json!({"layer": t, "variations": 3})).is_err());
let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else {
eprintln!("no variable font installed: rendering check skipped");
return;
};
let info = effectcraft_text::fonts::face(face).info.clone();
if effectcraft_text::resolve(&info.family, &info.style).face != face {
eprintln!("variable face not reachable by family/style: skipped");
return;
}
s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap();
let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).or_else(|| axes.iter().find(|a| a.max > a.min)).unwrap().clone();
let geom = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
let l = comp.layer(LayerId(t)).unwrap();
let paths = effectcraft_render::text::glyph_paths(&ctx, l);
let area: f64 = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum();
let right = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x1).fold(f64::MIN, f64::max);
(area, right)
};
s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.min}})).unwrap();
let lo = geom(&s);
s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.max}})).unwrap();
let hi = geom(&s);
assert!((hi.0 - lo.0).abs() > 1.0, "{} outlines {lo:?} → {hi:?}", a.tag);
assert!((hi.1 - lo.1).abs() > 0.5, "{} advances {lo:?} → {hi:?}", a.tag);
}
#[test]
fn camera_focus_commands_undo_and_serde() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 500]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
assert!(s.is_enabled("camera.setFocusToLayer"));
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}");
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}");
let back = roundtrip(&s);
assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some());
s.execute("edit.undo", json!({})).unwrap();
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
assert!(e["expression"].is_null());
s.execute("edit.undo", json!({})).unwrap();
let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
assert!((v - before).abs() < 1e-9);
s.execute("edit.redo", json!({})).unwrap();
// A camera alone can't link to a layer, but can link to its point of interest.
s.execute("layer.select", json!({"layers": [cam]})).unwrap();
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
#[test]
fn numeric_string_resolves_as_id() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0, "frameRate": 30})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.resolve_comp(Some(&json!(cid.0.to_string()))).unwrap(), cid);
assert!(s.resolve_comp(Some(&json!("999999"))).is_err());
let comp = s.project.comp(cid).unwrap();
assert_eq!(crate::commands::resolve_layer(comp, &json!(l.to_string())), Some(LayerId(l)));
assert_eq!(crate::commands::resolve_layer(comp, &json!("999999")), None);
let r = s.execute("layer.tree", json!({"layer": l.to_string(), "comp": cid.0.to_string()})).unwrap();
assert_eq!(r["id"].as_u64(), Some(l), "{r}");
}
/// Go to Work Area Start / End have After Effects' default keys (Shift+Home / Shift+End).
#[test]
fn work_area_navigation_has_after_effects_default_keys() {
let s = Session::default();
for (id, key) in [("time.workAreaStart", "Shift+Home"), ("time.workAreaEnd", "Shift+End")] {
let b = s.shortcuts().find(id).unwrap_or_else(|| panic!("{id} is bindable"));
assert_eq!(b.defaults, [key.to_string()], "{id}");
}
}