Files
effectcraft/crates/engine/src/tests.rs
T
bflatasticandClaude Opus 5.5 3318cb0da1 Import: dropped and picked files show an Importing card (#270)
Files dropped on the window or picked in the Import dialog were probed
one after the other on the UI thread: the app froze while a video was
read, nothing said what was being imported or how many files were
left, and only the last new item was selected. People ended up
importing the same file again and again.

file.import takes `background`: the files are probed in a background
job (Window > Progress, jobs.list / jobs.cancel) that names the file
being read and how many there are ("b.png (2 of 3)"); then they are
added in one undo step, all selected (the Project panel scrolls to
them, a file already in the project arrives again as in After
Effects) and a toast reports the count ("Imported 3 items", with the
first error). Drops and the Import dialog use it, and an Importing
card at the bottom left shows the job with its progress bar and
Cancel. Agents and scripts still import at once by default. The
toast registers an automation id.

Test: background_imports_report_progress_and_select_what_arrived
(engine), dropped_files_show_import_progress_then_the_items_they_made
(ui_drag_drop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:05:07 +07:00

545 lines
29 KiB
Rust

use serde_json::json;
use crate::Session;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
#[test]
fn demo_opens_and_renders() {
let s = demo();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, s.time(), effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
assert_eq!((img.width, img.height), (480, 270));
let lit = img.data.iter().filter(|p| p[3] > 0.99).count();
assert!(lit > 100_000, "{lit}");
}
/// The session's layer cache never changes pixels: scrubbing the demo (including back and
/// forth, and across an edit made through a command) matches cache-less renders exactly.
#[test]
fn demo_layer_cache_is_transparent() {
let mut s = demo();
let cid = s.active_comp_id().unwrap();
let opts = effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() };
let uncached = |s: &Session, t: f64| {
let mut r = effectcraft_render::Renderer::new(&s.project, s.footage.as_ref(), opts);
r.expr = s.expr.as_deref();
r.comp_frame(cid, effectcraft_time::Tick::from_seconds_f64(t))
};
let check = |s: &Session, t: f64| {
let a = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), opts);
let b = uncached(s, t);
assert!(a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|c| (p[c] - q[c]).abs() < 1e-6)), "t={t}");
};
for t in [0.5, 1.0, 3.0, 3.0334, 6.0, 3.0, 0.5] {
check(&s, t);
}
assert!(s.layer_cache.stats().hits > 0);
// Edit the title's glow through the command layer, then scrub again.
let title = s.project.comp(cid).unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap();
s.execute("prop.set", json!({"layer": title, "path": "effects/#1/radius", "value": 5.0})).unwrap();
for t in [3.0, 0.5] {
check(&s, t);
}
}
/// `layer.addItem` puts the layer at `index` (1-based) in the stack and starts it at `time`, as a
/// drop in the Timeline does (#89).
#[test]
fn add_item_at_a_stack_index_and_time() {
let mut s = Session::default();
let clip = s.execute("comp.new", json!({"name": "Clip", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 30, "duration": 4})).unwrap();
for name in ["C", "B", "A"] {
s.execute("layer.newSolid", json!({"name": name})).unwrap();
}
let names = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
s.execute("layer.addItem", json!({"item": clip, "index": 2, "time": 1.01})).unwrap();
assert_eq!(names(&s), ["A", "Clip", "B", "C"]);
let l = &s.active_comp().unwrap().layers[1];
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0), "on the nearest frame");
// Past the bottom: the bottom.
s.execute("layer.addItem", json!({"item": clip, "index": 99})).unwrap();
assert_eq!(names(&s).last().map(String::as_str), Some("Clip 2"));
assert!(s.execute("layer.addItem", json!({"item": clip, "index": 0})).is_err(), "1-based");
}
/// Files dropped on the Timeline or the Composition viewer (`file.import {addToComp}`) become
/// layers of the comp that was active, one under the other, where they were dropped (#85, #89).
#[test]
fn imported_files_go_into_the_comp_where_they_were_dropped() {
use effectcraft_project::{Footage, FootageKind};
struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<Footage, String> {
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
}
}
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bg"})).unwrap();
let params = json!({"paths": ["/drop/a.png", "/drop/b.png"], "addToComp": true, "position": [40.0, 50.0], "index": 1, "time": 1.0});
s.execute("file.import", params).unwrap();
let comp = s.active_comp().unwrap();
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["a.png", "b.png", "Bg"]);
for l in &comp.layers[..2] {
assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0])));
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0));
}
// Without `addToComp` they only import.
s.execute("file.import", json!({"paths": ["/drop/c.png"]})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y");
}
/// #270: files dropped or picked in the Import dialog (`file.import {background}`) are probed in
/// a background job that says which file it is reading and how many are left; then they arrive
/// in one undo step, all selected, with a count. The same file imported again arrives again.
#[test]
fn background_imports_report_progress_and_select_what_arrived() {
use effectcraft_project::{Footage, FootageKind, ItemId};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Probes file k once `allow` > k (a slow video; 5 s at most, so an import that waits for it
/// fails the test instead of hanging it); `.xyz` files fail.
struct Gated {
started: AtomicUsize,
allow: Arc<AtomicUsize>,
}
impl crate::Importer for Gated {
fn probe(&self, path: &str) -> Result<Footage, String> {
let k = self.started.fetch_add(1, Ordering::SeqCst);
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
while self.allow.load(Ordering::SeqCst) <= k && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(1));
}
if path.ends_with(".xyz") {
return Err("unsupported file".into());
}
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
}
}
let allow = Arc::new(AtomicUsize::new(1));
let mut s = Session { importer: Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: allow.clone() })), ..Default::default() };
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let undo = s.history.undo.len();
let r = s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.png", "/drop/a.png"], "background": true})).unwrap();
let job = r["job"].as_str().unwrap().to_string();
// The first file is through; the second is being read.
let running = |s: &mut Session| s.execute("jobs.list", json!({})).unwrap()["running"].as_array().unwrap().clone();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while running(&mut s)[0]["done"] != json!(1) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(2));
}
let row = running(&mut s)[0].clone();
assert_eq!(row["id"], json!(job));
assert_eq!(row["label"], json!("Importing 3 files"));
assert_eq!(row["message"], json!("b.png (2 of 3)"));
assert_eq!((row["done"].as_u64(), row["total"].as_u64()), (Some(1), Some(3)));
assert!(s.project.items.values().all(|i| i.name != "a.png"), "nothing is added before the import finishes");
allow.store(usize::MAX, Ordering::SeqCst);
s.execute("jobs.wait", json!({})).unwrap();
let names = |s: &Session, ids: &[ItemId]| ids.iter().map(|i| s.project.item(*i).unwrap().name.clone()).collect::<Vec<_>>();
assert_eq!(names(&s, &s.state.project_selection), ["a.png", "b.png", "a.png"], "everything imported is selected, the same file twice too");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
let rec = s.job_log.last().unwrap();
assert_eq!((rec.status.as_str(), rec.message.as_str()), ("done", "Imported 3 items"));
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { message, .. } if message == "Imported 3 items")));
// Dropped on the viewer: they become layers too; a file that can't be read is reported.
s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.xyz"], "background": true, "addToComp": true, "position": [10.0, 20.0]})).unwrap();
s.execute("jobs.wait", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["c.png"]);
assert_eq!(s.job_log.last().unwrap().message, "Imported 1 item. /drop/d.xyz: unsupported file");
// Agents and scripts import at once by default.
let r = s.execute("file.import", json!({"paths": ["/drop/e.png"]})).unwrap();
assert_eq!(r["items"].as_array().map(Vec::len), Some(1));
}
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
#[test]
fn audio_lock_and_shy_keep_cached_layers() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap();
s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
let cid = s.active_comp_id().unwrap();
let t = s.time();
s.render(cid, t, Default::default());
let before = s.layer_cache.stats();
for sw in ["audio", "lock", "shy"] {
s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": sw, "value": sw != "audio"})).unwrap();
}
s.render(cid, t, Default::default());
let after = s.layer_cache.stats();
assert_eq!(after.misses, before.misses, "no layer rendered again");
assert!(after.hits > before.hits);
}
#[test]
fn every_command_has_unique_id_and_runs_or_reports() {
let mut ids = std::collections::HashSet::new();
for c in crate::command_specs() {
assert!(ids.insert(c.id), "duplicate {}", c.id);
}
assert!(ids.len() >= 90, "{}", ids.len());
}
#[test]
fn layer_workflow_with_undo() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Test", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.newSolid", json!({"color": "#336699"})).unwrap();
let lid = r["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "transform/opacity", "value": 50})).unwrap();
let tree = s.execute("layer.tree", json!({"layer": lid})).unwrap();
assert!(tree.to_string().contains("\"value\":50.0"));
s.execute("prop.toggleAnimation", json!({"layer": lid, "path": "transform/position"})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "transform/position", "value": [100, 100]})).unwrap();
let comp = s.active_comp().unwrap();
let pos = comp.layers[0].props.prop("transform/position").unwrap();
assert_eq!(pos.keys.len(), 2);
s.execute("prop.select", json!({"layer": lid, "path": "transform/position"})).unwrap();
assert_eq!(s.state.selected_keys.len(), 2);
s.execute("keys.easyEase", json!({})).unwrap();
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
let pos = s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().clone();
assert!((pos.keys[0].time.seconds() - 0.5).abs() < 0.02);
assert_eq!(pos.keys[0].out_interp, effectcraft_keyframe::Interp::Bezier);
// undo back to before the move
s.execute("edit.undo", json!({})).unwrap();
let pos = s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().clone();
assert!(pos.keys[0].time.seconds().abs() < 0.02);
s.execute("effect.apply", json!({"layer": lid, "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/blurriness", "value": 12})).unwrap();
// Applying selects the new effect (Edit ▸ Duplicate would duplicate it); select the layer.
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
s.execute("layer.precompose", json!({"layers": [1, 2], "name": "Pre"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
}
#[test]
fn text_shape_mask_matte_parent() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 800, "height": 450, "duration": 3})).unwrap();
let t = s.execute("layer.newText", json!({"text": "Hello", "size": 90})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["position", "opacity"]})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "star", "fill": "#ffcc00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "trim"})).unwrap();
s.execute("layer.addMask", json!({"layer": sh, "shape": "ellipse"})).unwrap();
s.execute("layer.setMask", json!({"layer": sh, "mask": 1, "mode": "Subtract", "inverted": true})).unwrap();
s.execute("layer.setTrackMatte", json!({"layer": sh, "matte": t, "kind": "luma"})).unwrap();
s.execute("layer.setParent", json!({"layers": [sh], "parent": t})).unwrap();
assert!(s.execute("layer.setParent", json!({"layers": [t], "parent": sh})).is_err());
s.execute("layer.setBlendMode", json!({"layers": [sh], "mode": "Screen"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [sh], "switch": "threeD"})).unwrap();
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, s.time(), Default::default());
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!(info["layers"].as_array().unwrap().len(), 2);
}
/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn
/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include
/// an empty Path and a Gradient Stroke that render.
#[test]
fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap();
assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke");
s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap();
let p = paint(&mut s);
assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}");
s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap();
let p = paint(&mut s);
assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}");
// One stroke, before the fill (paths, stroke, fill), and each change is one undo step.
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
let group = l.props.group("contents/group").unwrap();
let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
assert_eq!(order, ["rect", "stroke", "fill"]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0));
// Only shape layers.
s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap();
assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err());
for kind in ["path", "gstroke"] {
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap();
assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}");
}
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, s.time(), Default::default());
}
/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the
/// viewer drags the whole mask (#203); a shape's Path item works the same way.
#[test]
fn selecting_a_mask_selects_its_points() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
let m1 = s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap()["mask"].as_u64().unwrap();
let m2 = s.execute("layer.addMask", json!({"layer": l, "shape": "ellipse"})).unwrap()["mask"].as_u64().unwrap();
let points = |s: &Session, m: u64| s.state.selected_vertices.iter().filter(|v| v.mask == m).count();
s.execute("prop.select", json!({"layer": l, "prop": m1, "selectKeys": false})).unwrap();
assert_eq!((points(&s, m1), s.state.selected_vertices.len()), (4, 4));
// Mask Path selects them too; Shift-adding another mask keeps the first.
let path = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().props.find_group(m2).unwrap().get("path").unwrap().uid;
s.execute("prop.select", json!({"layer": l, "prop": path, "add": true})).unwrap();
assert_eq!((points(&s, m1), points(&s, m2)), (4, 4));
// Another mask replaces them; another property deselects them.
s.execute("prop.select", json!({"layer": l, "prop": m2})).unwrap();
assert_eq!((points(&s, m1), points(&s, m2)), (0, 4));
// Delete removes the selected mask (not only its points).
s.execute("edit.clear", json!({})).unwrap();
let masks = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().masks().unwrap().children.len();
assert_eq!(masks(&s), 1);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
assert!(s.state.selected_vertices.is_empty());
let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
let star = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "path"})).unwrap()["uid"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": sh, "prop": star})).unwrap();
assert_eq!(s.state.selected_vertices.len(), 0, "an empty Path has no points");
}
#[test]
fn save_and_open_roundtrip() {
let mut s = demo();
let dir = std::env::temp_dir().join(format!("ec-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("demo.ecproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let before = s.project.clone();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(*before, *s2.project);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn help_links_emit_urls() {
let mut s = Session::default();
let r = s.execute("help.discord", json!({})).unwrap();
assert_eq!(r["url"], "https://discord.gg/artcraft");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.contains("discord"))));
assert_eq!(s.execute("help.github", json!({})).unwrap()["url"], "https://github.com/storytold/effectcraft");
}
#[test]
fn time_navigation() {
let mut s = demo();
s.execute("time.start", json!({})).unwrap();
let r = s.execute("time.nextKey", json!({})).unwrap();
assert!(r["time"].as_f64().unwrap() > 0.0);
s.execute("time.set", json!({"frame": 45})).unwrap();
assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50);
}
#[test]
fn time_navigation_per_property() {
let mut s = demo();
// An animated property and the comp times of its keys.
let comp = s.active_comp().unwrap().clone();
let mut found = None;
for l in &comp.layers {
l.props.walk("", &mut |_, pr| {
if found.is_none() && pr.keys.len() >= 2 {
found = Some((pr.uid, pr.keys.iter().map(|k| l.comp_time(k.time)).collect::<Vec<_>>()));
}
});
}
let (uid, times) = found.expect("demo has an animated property");
s.set_time(effectcraft_time::Tick::ZERO);
let half = comp.frame_duration().0 / 2;
let expect = times.iter().copied().filter(|t| t.0 > half).min().unwrap();
s.execute("time.go", json!({"to": "nextKey", "prop": uid})).unwrap();
let snap = |t| comp.frame_rate.snap(t);
assert_eq!(s.time(), snap(expect));
// Going back from past the last key lands on the last key.
s.set_time(comp.duration);
s.execute("time.go", json!({"to": "prevKey", "prop": uid})).unwrap();
assert_eq!(s.time(), snap(*times.iter().max().unwrap()));
}
#[test]
fn set_text_paragraph_fill_and_leading() {
use effectcraft_keyframe::{Justify, Value as KValue};
let mut s = demo();
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
let doc = |s: &Session| {
let l = s.active_comp().unwrap().layer(crate::project::LayerId(t)).unwrap().clone();
match &l.props.prop("text/sourceText").unwrap().value {
KValue::Text(d) => (**d).clone(),
v => panic!("{v:?}"),
}
};
for (key, j) in [
("justifyLeft", Justify::JustifyLastLeft),
("justifyCenter", Justify::JustifyLastCenter),
("justifyRight", Justify::JustifyLastRight),
("justifyAll", Justify::JustifyAll),
("center", Justify::Center),
] {
s.execute("layer.setText", json!({"layer": t, "justify": key})).unwrap();
assert_eq!(doc(&s).justify, j, "{key}");
}
s.execute("layer.setText", json!({"layer": t, "leading": 50, "applyFill": false, "applyStroke": true})).unwrap();
let d = doc(&s);
assert_eq!((d.leading, d.apply_fill, d.apply_stroke), (Some(50.0), false, true));
s.execute("layer.setText", json!({"layer": t, "leading": "auto"})).unwrap();
assert_eq!(doc(&s).leading, None);
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
let d = doc(&s);
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
}
#[test]
fn prop_get_and_render_rgba8() {
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();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [0, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 1.0, "value": [100, 50]})).unwrap();
let v = s.execute("prop.get", json!({"layer": l, "path": "transform/position", "time": 1.0})).unwrap();
assert_eq!(v["animated"], true);
assert_eq!(v["keys"].as_array().unwrap().len(), 2);
assert_eq!(v["value"][0].as_f64().unwrap(), 100.0);
let cid = s.resolve_comp(Some(&json!("T"))).unwrap();
let (w, h, rgba) = s.render_rgba8(cid, effectcraft_time::Tick::ZERO, 160).unwrap();
assert_eq!((w, h), (160, 90));
assert_eq!(rgba.len(), (w * h * 4) as usize);
}
#[test]
fn params_docs_parse() {
use crate::commands::accepted_params;
let k = |d: &str| accepted_params(d).unwrap();
assert_eq!(k("{layers: [id|name|#n], add?, toggle?}"), ["layers", "add", "toggle"]);
assert_eq!(k("{time? (s) | frame? | timecode?}"), ["time", "frame", "timecode"]);
assert_eq!(k("{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}"), ["interpolation", "in", "out", "autoBezier"]);
assert_eq!(k("{keys: [{layer, prop, time}], add?}"), ["keys", "add"]);
assert_eq!(k("{label: Red|Yellow|Aqua|…, layers?}"), ["label", "layers"]);
assert_eq!(k("{name?, background? [r,g,b]|#hex, open?}"), ["name", "background", "open"]);
assert!(k("{}").is_empty());
assert!(accepted_params("free text").is_none());
// Every registered command documents its params as a parseable key list.
for c in crate::command_specs() {
assert!(accepted_params(c.params).is_some(), "{}: {}", c.id, c.params);
}
}
#[test]
fn execute_checked_rejects_unknown_params() {
let mut s = demo();
let e = s.execute_checked("layer.select", json!({"index": 2})).unwrap_err().to_string();
assert!(e.contains("`index`") && e.contains("layers, add, toggle"), "{e}");
s.execute_checked("layer.select", json!({"layers": ["#2"]})).unwrap();
assert_eq!(s.state.selected_layers.len(), 1);
// Aliases and always-accepted keys.
s.execute_checked("layer.select", json!({"layer": "#1", "comp": s.active_comp_id().unwrap().0})).unwrap();
s.execute_checked("time.set", json!({"frame": 3})).unwrap();
assert!(s.execute_checked("edit.undo", json!({"steps": 2})).is_err());
// Non-object params are not validated; the unchecked path stays lenient.
s.execute("layer.select", json!({"layers": ["#1"], "index": 2})).unwrap();
}
#[test]
fn layer_tree_paths_resolve() {
let mut s = demo();
s.execute("effect.apply", json!({"layer": 1, "effect": "Gaussian Blur"})).unwrap();
let comp = s.execute("comp.info", json!({})).unwrap();
let mut checked = 0;
for l in comp["layers"].as_array().unwrap() {
let tree = s.execute("layer.tree", json!({"layer": l["id"]})).unwrap();
let mut stack = vec![tree["properties"].clone()];
while let Some(n) = stack.pop() {
if let Some(ch) = n["children"].as_array() {
stack.extend(ch.iter().cloned());
continue;
}
let path = n["path"].as_str().unwrap();
let got = s.execute("prop.get", json!({"layer": l["id"], "path": path})).unwrap();
assert_eq!(got["uid"], n["uid"], "path {path} on layer {}", l["name"]);
checked += 1;
}
}
assert!(checked > 20, "{checked}");
}
#[test]
fn comp_settings_anchor_start_timecode_renderer() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "A", "width": 100, "height": 100, "duration": 2.0})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [10, 20]})).unwrap();
let pos = |s: &Session| {
let pr = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone();
pr.keys[0].value.components()
};
// Center anchor: grow by 100x50 → shift by half.
s.execute("comp.settings", json!({"width": 200, "height": 150})).unwrap();
assert_eq!(pos(&s)[..2], [60.0, 45.0]);
// Top-left anchor: no shift. Bottom-right: full shift.
s.execute("comp.settings", json!({"width": 300, "anchor": 0})).unwrap();
assert_eq!(pos(&s)[..2], [60.0, 45.0]);
s.execute("comp.settings", json!({"width": 200, "height": 100, "anchor": 8})).unwrap();
assert_eq!(pos(&s)[..2], [-40.0, -5.0]);
s.execute("comp.settings", json!({"startTimecode": "0:00:01:00", "renderer": "advanced3D"})).unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.display_start, c.frame_rate.tick_of(30));
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
}
/// `advanced3D` is a value of `renderer`, not a key: comp.new / comp.settings don't advertise it
/// in their schemas and reject it as unknown instead of ignoring it (#177).
#[test]
fn renderer_values_are_not_parameter_keys() {
let mut s = Session::default();
for (id, p) in [("comp.new", json!({"name": "A"})), ("comp.settings", json!({}))] {
let spec = crate::command_specs().iter().find(|c| c.id == id).unwrap();
let keys = crate::commands::accepted_params(spec.params).unwrap();
assert!(keys.contains(&"renderer".to_string()) && !keys.contains(&"advanced3D".to_string()), "{id}: {keys:?}");
let mut bad = p.clone();
bad["advanced3D"] = json!(true);
let e = s.execute_checked(id, bad).unwrap_err().to_string();
assert!(e.contains("unknown parameter(s) `advanced3D`"), "{id}: {e}");
let mut good = p;
good["renderer"] = json!("advanced3D");
s.execute_checked(id, good).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, crate::project::Renderer::Advanced3D, "{id}");
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
}
}
#[test]
fn comp_rejects_zero_and_negative_frame_rates() {
let mut s = Session::default();
for fps in [0.0, -30.0, 0.0001] {
assert!(s.execute("comp.new", json!({"frameRate": fps})).is_err(), "{fps}");
}
s.execute("comp.new", json!({"frameRate": 30, "duration": 1})).unwrap();
assert!(s.execute("comp.settings", json!({"frameRate": 0})).is_err());
assert_eq!(s.active_comp().unwrap().frame_rate, crate::time::FrameRate::FPS_30);
}
#[test]
fn render_queue_add_accepts_documented_settings_when_checked() {
let mut s = demo();
let r = s.execute_checked(
"renderQueue.add",
json!({"format": "gif", "output": "/tmp/x.gif", "resolution": "quarter", "timeSpan": "custom", "start": 0.0, "end": 0.5, "quality": "draft", "loop": true}),
);
// Without an exporter the add itself still succeeds (rendering is what needs one).
assert!(r.is_ok(), "{r:?}");
assert!(s.execute_checked("renderQueue.add", json!({"bogus": 1})).is_err());
}