Files
effectcraft/crates/engine/src/tests_tail.rs
T
Brandon ThomasandClaude Opus 5.5 f89af341a6 M13.6: variable font axes in the character style
CharStyle/TextDoc variations (layer.setText variations), shaped with harfrust instances so
advances follow HVAR/gvar, outlines drawn at the design-space position, animator deltas relative
to the style's values, Character panel Variable Font Axes fields (character.axis.<tag>).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:36:53 -04:00

403 lines
24 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 = listed["axes"][0]["tag"].as_str().unwrap().to_string();
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>()
};
let before = area(&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 range = (axis.max - axis.min) 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);
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());
}