diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8aa227bc8..434c2b685 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1385,98 +1385,6 @@ pub fn upgrade_effects(p: &mut Project) { mod rq_tests; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_3d; -#[cfg(test)] -mod tests_anim_presets; -#[cfg(test)] -mod tests_anim_tools; -#[cfg(test)] -mod tests_camera_track; -#[cfg(test)] -mod tests_codec_options; -#[cfg(test)] -mod tests_color_view; -#[cfg(test)] -mod tests_crypto_pick; -#[cfg(test)] -mod tests_delete_keys; -#[cfg(test)] -mod tests_disk_cache; -#[cfg(test)] -mod tests_effects; -#[cfg(test)] -mod tests_essential; -#[cfg(test)] -mod tests_essential_more; -#[cfg(test)] -mod tests_face; -#[cfg(test)] -mod tests_fidelity; -#[cfg(test)] -mod tests_frame_export; -#[cfg(test)] -mod tests_fuzz_open; -#[cfg(test)] -mod tests_import_project; -#[cfg(test)] -mod tests_key_metadata_order; -#[cfg(test)] -mod tests_keylight; -#[cfg(test)] -mod tests_limits; -#[cfg(test)] -mod tests_lottie; -#[cfg(test)] -mod tests_m137; -#[cfg(test)] -mod tests_m145; -#[cfg(test)] -mod tests_markers; -#[cfg(test)] -mod tests_mask_warp; -#[cfg(test)] -mod tests_menu_cmds; -#[cfg(test)] -mod tests_model3d; -#[cfg(test)] -mod tests_motion_blur_depth; -#[cfg(test)] -mod tests_project_items; -#[cfg(test)] -mod tests_proxy; -#[cfg(test)] -mod tests_quick_offset; -#[cfg(test)] -mod tests_replace_source; -#[cfg(test)] -mod tests_rig3d; -#[cfg(test)] -mod tests_sequence; -#[cfg(test)] -mod tests_settings; -#[cfg(test)] -mod tests_shortcut_cmds; -#[cfg(test)] -mod tests_stubs; -#[cfg(test)] -mod tests_styles; -#[cfg(test)] -mod tests_tail; -#[cfg(test)] -mod tests_text; -#[cfg(test)] -mod tests_text_edit; -#[cfg(test)] -mod tests_timeline; -#[cfg(test)] -mod tests_timeline_interchange; -#[cfg(test)] -mod tests_track; -#[cfg(test)] -mod tests_vector_import; -#[cfg(test)] -mod tests_viewer; /// Font families available to text layers (bundled + scanned system fonts). /// Text animation presets: (id, name), for menus. @@ -1579,16 +1487,4 @@ pub fn font_menu(prefs: &prefs::Prefs) -> Vec { out.extend(all.iter().map(|f| row(f, false))); out } -#[cfg(test)] -mod tests_history; -#[cfg(test)] -mod tests_models; -#[cfg(test)] -mod tests_paint; -#[cfg(test)] -mod tests_panels; -#[cfg(test)] -mod tests_roto; -#[cfg(test)] -mod tests_key_time; diff --git a/crates/engine/src/offload/tests.rs b/crates/engine/src/offload/tests.rs index 55c349e1d..b448d09c4 100644 --- a/crates/engine/src/offload/tests.rs +++ b/crates/engine/src/offload/tests.rs @@ -240,7 +240,7 @@ fn pose(f: u32) -> ([f64; 2], f64, f64) { #[test] fn track_analysis_runs_through_the_offload_as_one_undo_step() { - let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); + let (mut s, clip, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose)); s.offload = Some(Arc::new(InlineWith(s.footage.clone()))); s.execute("track.motion", serde_json::json!({})).unwrap(); s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap(); @@ -254,7 +254,7 @@ fn track_analysis_runs_through_the_offload_as_one_undo_step() { let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone(); let fc = tp.get("featureCenter").unwrap(); assert_eq!(fc.keys.len(), 25); - let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2(); + let v = fc.value_at(crate::tests::track::frame_time(12)).as_vec2(); assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}"); assert_eq!(s.history.undo.len(), undo + 1); assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track"); @@ -270,7 +270,7 @@ fn track_analysis_runs_through_the_offload_as_one_undo_step() { #[test] fn cancelling_offloaded_jobs() { - let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose)); + let (mut s, _, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose)); let off = Arc::new(Hanging::default()); s.offload = Some(off.clone()); s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })); diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index 81a187226..4c39cc89e 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -67,11 +67,11 @@ fn add_configure_render() { let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap(); assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}"); assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err()); - let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("rq/out.mov")})).unwrap(); + let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests::stubs::tmp_out("rq/out.mov")})).unwrap(); assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes"); - assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov"))); + assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests::stubs::tmp_out("rq/out.mov"))); // A second item that fails, a third that is unqueued. - let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/fail.gif")})).unwrap(); + let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests::stubs::tmp_out("rq/fail.gif")})).unwrap(); let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap(); s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap(); assert_eq!(s.project.render_queue[2].status.label(), "Unqueued"); @@ -79,12 +79,12 @@ fn add_configure_render() { assert_eq!(r["items"].as_array().unwrap().len(), 2); assert_eq!( ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::>(), - [std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))] + [std::path::PathBuf::from(crate::tests::stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests::stubs::tmp_out("rq/fail.gif"))] ); let q = &s.project.render_queue; assert_eq!(q[0].status.label(), "Done"); assert!(q[0].started.is_some() && q[0].render_time.is_some()); - assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov"))); + assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests::stubs::tmp_out("rq/out.mov"))); assert_eq!(q[1].status, RenderStatus::Failed("boom".into())); assert_eq!(q[2].status.label(), "Unqueued"); let events = s.drain_events(); @@ -130,8 +130,8 @@ fn failure_toast_names_the_first_reason() { } let mut s = demo(); s.exporter = Some(Arc::new(Failing)); - s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/first.gif")})).unwrap(); - s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/second.gif")})).unwrap(); + s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests::stubs::tmp_out("rq/first.gif")})).unwrap(); + s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests::stubs::tmp_out("rq/second.gif")})).unwrap(); s.execute("renderQueue.render", json!({})).unwrap(); let events = s.drain_events(); let toast = events.iter().find_map(|e| if let Event::Toast { message, error: true } = e { Some(message.as_str()) } else { None }); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index a56d1849b..7abdfb53d 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -120,7 +120,7 @@ impl Session { // opens a disk cache (often the platform folder, through a settings store) would take // over the store of a roto test running beside it. Serialise them with the roto tests. #[cfg(test)] - crate::tests_roto::hold_roto_cache_test_lock(); + crate::tests::roto::hold_roto_cache_test_lock(); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b3f0602b3..01dd1d5e9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,735 +1,53 @@ -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::>(); - 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 { - 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::>(), ["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, - } - impl crate::Importer for Gated { - fn probe(&self, path: &str) -> Result { - 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::>(); - 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::>(), ["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()); -} - -/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732): -/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its -/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none. -#[test] -fn toolbar_fill_and_stroke_kinds_change_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 items = |s: &Session| -> Vec<(String, bool)> { - let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); - l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect() - }; - let kinds = |s: &mut Session| { - let p = s.execute("shape.fillStroke", json!({})).unwrap(); - (p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string()) - }; - assert_eq!(kinds(&mut s), ("solid".into(), "none".into())); - let cid = s.active_comp_id().unwrap(); - let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150); - let red = centre(&s); - // None switches the fill off: nothing is drawn, and the item stays. - s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap(); - assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]); - assert_eq!(kinds(&mut s).0, "none"); - assert_ne!(centre(&s), red, "the fill is off"); - // A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity. - s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap(); - assert_eq!(centre(&s), red); - let opacity = |s: &Session, m: &str| { - let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); - l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64() - }; - s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap(); - s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap(); - assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]); - assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0)); - s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap(); - assert_eq!(kinds(&mut s).0, "linear"); - s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap(); - assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]); - assert_eq!(opacity(&s, "fill"), 40.0); - // A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step. - s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap(); - assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]); - s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap(); - assert_eq!(kinds(&mut s), ("solid".into(), "none".into())); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(kinds(&mut s), ("solid".into(), "linear".into())); - assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err()); -} - -/// 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::>())); - } - }); - } - 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 property_groups_report_actionable_errors() { - let mut s = Session::default(); - s.execute("comp.new", json!({"name": "T"})).unwrap(); - let l = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); - let uid = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.group("text/pathOptions").unwrap().uid; - for selector in [json!({"path": "text"}), json!({"path": "text/pathOptions"}), json!({"path": format!("@{uid}")}), json!({"prop": uid})] { - let mut params = selector; - params["layer"] = json!(l); - for cmd in ["prop.get", "prop.set"] { - let err = s.execute(cmd, params.clone()).unwrap_err().to_string(); - assert!(err.contains("is a property group"), "{cmd}: {err}"); - assert!(err.contains("get_layer"), "{cmd}: {err}"); - } - } - assert!(s.execute("prop.get", json!({"layer": l, "path": "text/sourceText"})).is_ok()); - let err = s.execute("prop.get", json!({"layer": l, "path": "missing"})).unwrap_err().to_string(); - assert!(err.contains("no property `missing`"), "{err}"); -} - -#[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()); -} - -#[test] -fn new_text_rejects_malformed_positions_without_creating_a_layer() { - let mut s = Session::default(); - s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap(); - for position in [json!([]), json!([10]), json!(["x", 20]), json!([null, 20])] { - let err = s.execute("layer.newText", json!({"text": "Hello", "position": position})).unwrap_err(); - assert!(err.to_string().contains("position: [x, y]"), "{err}"); - assert!(s.active_comp().unwrap().layers.is_empty()); - } - s.execute("layer.newText", json!({"name": "Valid", "text": "Hello", "position": [10, 20]})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 0.0])); - s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 2); -} - -/// The active comp and the open comp tabs are saved with the project and restored on open (#748). -#[test] -fn reopening_a_project_restores_its_active_comp() { - use effectcraft_project::ItemId; - let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("view.ecproj").to_string_lossy().to_string(); - let mut s = Session::default(); - let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap(); - let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap(); - let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap(); - s.execute("comp.open", json!({"comp": "CompB"})).unwrap(); - s.state.open_comps.retain(|id| id.0 != c); - s.execute("file.saveAs", json!({"path": path})).unwrap(); - let mut s2 = Session::default(); - s2.execute("file.open", json!({"path": path})).unwrap(); - assert_eq!(s2.state.active_comp, Some(ItemId(b))); - assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]); - assert!(!s2.is_dirty()); - - // A file from before the view was saved opens on its first comp, as it always did. - let text = std::fs::read_to_string(&path).unwrap(); - let mut old: serde_json::Value = serde_json::from_str(&text).unwrap(); - let obj = old.as_object_mut().unwrap(); - obj.remove("activeComp"); - obj.remove("openComps"); - std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); - let mut s3 = Session::default(); - s3.execute("file.open", json!({"path": path})).unwrap(); - assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)])); - - // Ids that no longer name a comp (edited by hand, or written by another tool) fall back. - obj_set(&mut old, json!(999), json!([999, b, "x"])); - std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); - let mut s4 = Session::default(); - s4.execute("file.open", json!({"path": path})).unwrap(); - assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)])); - obj_set(&mut old, json!(999), json!([])); - std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); - let mut s5 = Session::default(); - s5.execute("file.open", json!({"path": path})).unwrap(); - assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)])); - - // XML copies carry it too. - let xml = dir.join("view.ecprojx").to_string_lossy().to_string(); - s.execute("file.saveAs", json!({"path": xml})).unwrap(); - let mut s6 = Session::default(); - s6.execute("file.open", json!({"path": xml})).unwrap(); - assert_eq!(s6.state.active_comp, Some(ItemId(b))); - let _ = std::fs::remove_dir_all(dir); - - fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) { - let o = v.as_object_mut().unwrap(); - o.insert("activeComp".into(), active); - o.insert("openComps".into(), open); - } -} - -/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the -/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714). -#[test] -fn reopening_a_project_restores_layer_times_and_current_time() { - use effectcraft_project::ItemId; - use effectcraft_time::{FrameRate, Tick}; - let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("times.ecproj").to_string_lossy().to_string(); - let mut s = Session::default(); - s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap(); - s.execute("layer.newSolid", json!({"name": "crown"})).unwrap(); - let cid = s.active_comp_id().unwrap(); - let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); - comp.frame_rate = FrameRate::FPS_59_94; - // The layer from the issue: 34f, 315f and 3033f at 59.94 fps. - let l = comp.layers.first_mut().unwrap(); - (l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)); - s.execute("time.set", json!({"time": 6.0})).unwrap(); - let t = s.time(); - // A second comp keeps its own time. - let b = ItemId(s.execute("comp.new", json!({"name": "B"})).unwrap()["comp"].as_u64().unwrap()); - s.execute("time.set", json!({"time": 2.0})).unwrap(); - let tb = s.time(); - s.execute("comp.open", json!({"comp": "C"})).unwrap(); - s.execute("file.saveAs", json!({"path": path})).unwrap(); - - let mut s2 = Session::default(); - s2.execute("file.open", json!({"path": path})).unwrap(); - let l = s2.project.comp(cid).unwrap().layers.first().unwrap(); - assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800))); - assert_eq!((s2.time(), s2.state.times.get(&b).copied()), (t, Some(tb))); - assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time"); - assert!(!s2.is_dirty()); - - // Hand-edited times past the end or before the start land on the last or first frame. - let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - file["compTimes"] = json!({cid.0.to_string(): i64::MAX, b.0.to_string(): -5}); - std::fs::write(&path, file.to_string()).unwrap(); - let mut s3 = Session::default(); - s3.execute("file.open", json!({"path": path})).unwrap(); - let c = s3.project.comp(cid).unwrap(); - assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); - assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); - assert_eq!(s3.state.times.get(&b).copied(), Some(Tick::ZERO)); -} +mod session; +mod three_d; +mod anim_presets; +mod anim_tools; +mod camera_track; +mod codec_options; +mod color_view; +mod crypto_pick; +mod delete_keys; +mod disk_cache; +mod effects; +mod essential; +mod essential_more; +mod face; +mod fidelity; +mod frame_export; +mod fuzz_open; +mod import_project; +mod key_metadata_order; +mod keylight; +mod limits; +mod lottie; +mod m137; +mod m145; +mod markers; +mod mask_warp; +mod menu_cmds; +mod model3d; +mod motion_blur_depth; +mod project_items; +mod proxy; +mod quick_offset; +mod replace_source; +mod rig3d; +mod sequence; +mod settings; +mod shortcut_cmds; +pub(crate) mod stubs; +mod styles; +mod tail; +mod text; +mod text_edit; +mod timeline; +mod timeline_interchange; +pub(crate) mod track; +mod vector_import; +mod viewer; +mod history; +mod models; +mod paint; +mod panels; +pub(crate) mod roto; +mod key_time; diff --git a/crates/engine/src/tests_anim_presets.rs b/crates/engine/src/tests/anim_presets.rs similarity index 100% rename from crates/engine/src/tests_anim_presets.rs rename to crates/engine/src/tests/anim_presets.rs diff --git a/crates/engine/src/tests_anim_tools.rs b/crates/engine/src/tests/anim_tools.rs similarity index 99% rename from crates/engine/src/tests_anim_tools.rs rename to crates/engine/src/tests/anim_tools.rs index 69b6afa06..06d194001 100644 --- a/crates/engine/src/tests_anim_tools.rs +++ b/crates/engine/src/tests/anim_tools.rs @@ -3,7 +3,7 @@ use effectcraft_keyframe::evaluate; use serde_json::json; -use crate::tests_timeline::{animate, prop, setup}; +use crate::tests::timeline::{animate, prop, setup}; fn select(s: &mut crate::Session, layer: u64, path: &str) { s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap(); diff --git a/crates/engine/src/tests_camera_track.rs b/crates/engine/src/tests/camera_track.rs similarity index 99% rename from crates/engine/src/tests_camera_track.rs rename to crates/engine/src/tests/camera_track.rs index 5bd5539c1..cc6378c4e 100644 --- a/crates/engine/src/tests_camera_track.rs +++ b/crates/engine/src/tests/camera_track.rs @@ -11,7 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3}; use serde_json::{Value, json}; use crate::Session; -use crate::tests_track::{H, W, frame_time, setup, texture}; +use crate::tests::track::{H, W, frame_time, setup, texture}; const F: f64 = 260.0; diff --git a/crates/engine/src/tests_codec_options.rs b/crates/engine/src/tests/codec_options.rs similarity index 100% rename from crates/engine/src/tests_codec_options.rs rename to crates/engine/src/tests/codec_options.rs diff --git a/crates/engine/src/tests_color_view.rs b/crates/engine/src/tests/color_view.rs similarity index 100% rename from crates/engine/src/tests_color_view.rs rename to crates/engine/src/tests/color_view.rs diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs similarity index 100% rename from crates/engine/src/tests_crypto_pick.rs rename to crates/engine/src/tests/crypto_pick.rs diff --git a/crates/engine/src/tests_delete_keys.rs b/crates/engine/src/tests/delete_keys.rs similarity index 100% rename from crates/engine/src/tests_delete_keys.rs rename to crates/engine/src/tests/delete_keys.rs diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests/disk_cache.rs similarity index 99% rename from crates/engine/src/tests_disk_cache.rs rename to crates/engine/src/tests/disk_cache.rs index 3091964ad..4204b8238 100644 --- a/crates/engine/src/tests_disk_cache.rs +++ b/crates/engine/src/tests/disk_cache.rs @@ -30,7 +30,7 @@ fn project(s: &mut Session) -> effectcraft_project::ItemId { #[test] fn disk_cache_settings_persistence_and_invalidation() { - crate::tests_roto::hold_roto_cache_test_lock(); + crate::tests::roto::hold_roto_cache_test_lock(); let dir = folder("persist"); let mut s = session(&dir); let st = s.execute_checked("cache.diskStats", json!({})).unwrap(); diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests/effects.rs similarity index 100% rename from crates/engine/src/tests_effects.rs rename to crates/engine/src/tests/effects.rs diff --git a/crates/engine/src/tests_essential.rs b/crates/engine/src/tests/essential.rs similarity index 100% rename from crates/engine/src/tests_essential.rs rename to crates/engine/src/tests/essential.rs diff --git a/crates/engine/src/tests_essential_more.rs b/crates/engine/src/tests/essential_more.rs similarity index 100% rename from crates/engine/src/tests_essential_more.rs rename to crates/engine/src/tests/essential_more.rs diff --git a/crates/engine/src/tests_face.rs b/crates/engine/src/tests/face.rs similarity index 99% rename from crates/engine/src/tests_face.rs rename to crates/engine/src/tests/face.rs index 9e713ff8b..2a45da98f 100644 --- a/crates/engine/src/tests_face.rs +++ b/crates/engine/src/tests/face.rs @@ -6,7 +6,7 @@ use effectcraft_track::face::{FaceParams, LANDMARKS, MEASUREMENTS, render_synthe use serde_json::json; use crate::mask_track::MaskMethod; -use crate::tests_track::{H, W, frame_time, setup}; +use crate::tests::track::{H, W, frame_time, setup}; fn face(f: u32) -> FaceParams { let t = f as f64; diff --git a/crates/engine/src/tests_fidelity.rs b/crates/engine/src/tests/fidelity.rs similarity index 100% rename from crates/engine/src/tests_fidelity.rs rename to crates/engine/src/tests/fidelity.rs diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests/frame_export.rs similarity index 100% rename from crates/engine/src/tests_frame_export.rs rename to crates/engine/src/tests/frame_export.rs diff --git a/crates/engine/src/tests_fuzz_open.rs b/crates/engine/src/tests/fuzz_open.rs similarity index 100% rename from crates/engine/src/tests_fuzz_open.rs rename to crates/engine/src/tests/fuzz_open.rs diff --git a/crates/engine/src/tests_history.rs b/crates/engine/src/tests/history.rs similarity index 100% rename from crates/engine/src/tests_history.rs rename to crates/engine/src/tests/history.rs diff --git a/crates/engine/src/tests_import_project.rs b/crates/engine/src/tests/import_project.rs similarity index 100% rename from crates/engine/src/tests_import_project.rs rename to crates/engine/src/tests/import_project.rs diff --git a/crates/engine/src/tests_key_metadata_order.rs b/crates/engine/src/tests/key_metadata_order.rs similarity index 100% rename from crates/engine/src/tests_key_metadata_order.rs rename to crates/engine/src/tests/key_metadata_order.rs diff --git a/crates/engine/src/tests_key_time.rs b/crates/engine/src/tests/key_time.rs similarity index 99% rename from crates/engine/src/tests_key_time.rs rename to crates/engine/src/tests/key_time.rs index d42d6d4a0..b5ec3a159 100644 --- a/crates/engine/src/tests_key_time.rs +++ b/crates/engine/src/tests/key_time.rs @@ -4,7 +4,7 @@ use effectcraft_project::LayerId; use effectcraft_time::Tick; use serde_json::{Value, json}; -use crate::{Session, tests_timeline::prop}; +use crate::{Session, tests::timeline::prop}; fn offset_layer(start: f64, stretch: f64) -> (Session, u64) { let mut s = Session::default(); diff --git a/crates/engine/src/tests_keylight.rs b/crates/engine/src/tests/keylight.rs similarity index 100% rename from crates/engine/src/tests_keylight.rs rename to crates/engine/src/tests/keylight.rs diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests/limits.rs similarity index 100% rename from crates/engine/src/tests_limits.rs rename to crates/engine/src/tests/limits.rs diff --git a/crates/engine/src/tests_lottie.rs b/crates/engine/src/tests/lottie.rs similarity index 100% rename from crates/engine/src/tests_lottie.rs rename to crates/engine/src/tests/lottie.rs diff --git a/crates/engine/src/tests_m137.rs b/crates/engine/src/tests/m137.rs similarity index 100% rename from crates/engine/src/tests_m137.rs rename to crates/engine/src/tests/m137.rs diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests/m145.rs similarity index 99% rename from crates/engine/src/tests_m145.rs rename to crates/engine/src/tests/m145.rs index 68b897617..ce0cb34d4 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests/m145.rs @@ -345,7 +345,7 @@ fn render_queue_uses_default_folder_bit_depth_names_and_segments() { fn every_wired_setting_is_live_and_display_only_ones_are_documented() { let todo = crate::prefs::todo_keys(); assert_eq!(todo, ["general.useSystemColorPicker", "composition.hardwareAcceleratePanels", "scripting.enableJsDebugger"]); - let docs = include_str!("../../../docs/preferences.md"); + let docs = include_str!("../../../../docs/preferences.md"); assert!(docs.contains("### Display-only settings")); assert!(!docs.contains("Not wired yet")); } diff --git a/crates/engine/src/tests_markers.rs b/crates/engine/src/tests/markers.rs similarity index 99% rename from crates/engine/src/tests_markers.rs rename to crates/engine/src/tests/markers.rs index afe4b9890..66aac5694 100644 --- a/crates/engine/src/tests_markers.rs +++ b/crates/engine/src/tests/markers.rs @@ -3,7 +3,7 @@ use serde_json::json; -use crate::tests_timeline::setup; +use crate::tests::timeline::setup; #[test] fn comp_marker_dialog_fields_and_undo() { diff --git a/crates/engine/src/tests_mask_warp.rs b/crates/engine/src/tests/mask_warp.rs similarity index 99% rename from crates/engine/src/tests_mask_warp.rs rename to crates/engine/src/tests/mask_warp.rs index 137feb1cb..e20ec7cb6 100644 --- a/crates/engine/src/tests_mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -11,7 +11,7 @@ use effectcraft_track::Homography; use serde_json::json; use crate::Session; -use crate::tests_track::{FPS, H, W, frame_time, patch_frames, rotating_pose, setup, texture}; +use crate::tests::track::{FPS, H, W, frame_time, patch_frames, rotating_pose, setup, texture}; fn pose_h(f: u32) -> Homography { let (c, rot, sc) = rotating_pose(f); diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs similarity index 99% rename from crates/engine/src/tests_menu_cmds.rs rename to crates/engine/src/tests/menu_cmds.rs index 5131bf3f4..eb99d809f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -641,7 +641,7 @@ fn work_area_stays_ordered_when_the_start_passes_the_end() { #[test] fn label_group_purge_and_edit_original() { - crate::tests_roto::hold_roto_cache_test_lock(); + crate::tests::roto::hold_roto_cache_test_lock(); let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); diff --git a/crates/engine/src/tests_model3d.rs b/crates/engine/src/tests/model3d.rs similarity index 100% rename from crates/engine/src/tests_model3d.rs rename to crates/engine/src/tests/model3d.rs diff --git a/crates/engine/src/tests_models.rs b/crates/engine/src/tests/models.rs similarity index 98% rename from crates/engine/src/tests_models.rs rename to crates/engine/src/tests/models.rs index 66111eb34..21d316e75 100644 --- a/crates/engine/src/tests_models.rs +++ b/crates/engine/src/tests/models.rs @@ -28,7 +28,7 @@ fn wait_loaded(s: &mut Session, task: Task) { #[test] fn models_are_listed_chosen_and_verified() { - crate::tests_roto::hold_roto_cache_test_lock(); + crate::tests::roto::hold_roto_cache_test_lock(); let (mut s, d) = session("list"); std::fs::create_dir_all(&d).unwrap(); let bogus = d.join("bogus.bin"); diff --git a/crates/engine/src/tests_motion_blur_depth.rs b/crates/engine/src/tests/motion_blur_depth.rs similarity index 100% rename from crates/engine/src/tests_motion_blur_depth.rs rename to crates/engine/src/tests/motion_blur_depth.rs diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests/paint.rs similarity index 100% rename from crates/engine/src/tests_paint.rs rename to crates/engine/src/tests/paint.rs diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests/panels.rs similarity index 100% rename from crates/engine/src/tests_panels.rs rename to crates/engine/src/tests/panels.rs diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests/project_items.rs similarity index 100% rename from crates/engine/src/tests_project_items.rs rename to crates/engine/src/tests/project_items.rs diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests/proxy.rs similarity index 100% rename from crates/engine/src/tests_proxy.rs rename to crates/engine/src/tests/proxy.rs diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests/quick_offset.rs similarity index 100% rename from crates/engine/src/tests_quick_offset.rs rename to crates/engine/src/tests/quick_offset.rs diff --git a/crates/engine/src/tests_replace_source.rs b/crates/engine/src/tests/replace_source.rs similarity index 100% rename from crates/engine/src/tests_replace_source.rs rename to crates/engine/src/tests/replace_source.rs diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests/rig3d.rs similarity index 100% rename from crates/engine/src/tests_rig3d.rs rename to crates/engine/src/tests/rig3d.rs diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests/roto.rs similarity index 99% rename from crates/engine/src/tests_roto.rs rename to crates/engine/src/tests/roto.rs index 33500b4ec..423af1d2f 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -8,7 +8,7 @@ use effectcraft_track::roto::rle; use serde_json::{Value, json}; use crate::Session; -use crate::tests_track::{H, W, frame_time, setup}; +use crate::tests::track::{H, W, frame_time, setup}; /// Keep cache/model mutation tests from clearing segmentations under another fixture. /// Libtest runs each test on its own thread; the guard lives until that thread exits. diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests/sequence.rs similarity index 100% rename from crates/engine/src/tests_sequence.rs rename to crates/engine/src/tests/sequence.rs diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs new file mode 100644 index 000000000..b3f0602b3 --- /dev/null +++ b/crates/engine/src/tests/session.rs @@ -0,0 +1,735 @@ +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::>(); + 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 { + 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::>(), ["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, + } + impl crate::Importer for Gated { + fn probe(&self, path: &str) -> Result { + 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::>(); + 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::>(), ["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()); +} + +/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732): +/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its +/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none. +#[test] +fn toolbar_fill_and_stroke_kinds_change_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 items = |s: &Session| -> Vec<(String, bool)> { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); + l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect() + }; + let kinds = |s: &mut Session| { + let p = s.execute("shape.fillStroke", json!({})).unwrap(); + (p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string()) + }; + assert_eq!(kinds(&mut s), ("solid".into(), "none".into())); + let cid = s.active_comp_id().unwrap(); + let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150); + let red = centre(&s); + // None switches the fill off: nothing is drawn, and the item stays. + s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]); + assert_eq!(kinds(&mut s).0, "none"); + assert_ne!(centre(&s), red, "the fill is off"); + // A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity. + s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap(); + assert_eq!(centre(&s), red); + let opacity = |s: &Session, m: &str| { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); + l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64() + }; + s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap(); + s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]); + assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0)); + s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap(); + assert_eq!(kinds(&mut s).0, "linear"); + s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]); + assert_eq!(opacity(&s, "fill"), 40.0); + // A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step. + s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap(); + assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]); + s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap(); + assert_eq!(kinds(&mut s), ("solid".into(), "none".into())); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&mut s), ("solid".into(), "linear".into())); + assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err()); +} + +/// 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::>())); + } + }); + } + 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 property_groups_report_actionable_errors() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T"})).unwrap(); + let l = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.group("text/pathOptions").unwrap().uid; + for selector in [json!({"path": "text"}), json!({"path": "text/pathOptions"}), json!({"path": format!("@{uid}")}), json!({"prop": uid})] { + let mut params = selector; + params["layer"] = json!(l); + for cmd in ["prop.get", "prop.set"] { + let err = s.execute(cmd, params.clone()).unwrap_err().to_string(); + assert!(err.contains("is a property group"), "{cmd}: {err}"); + assert!(err.contains("get_layer"), "{cmd}: {err}"); + } + } + assert!(s.execute("prop.get", json!({"layer": l, "path": "text/sourceText"})).is_ok()); + let err = s.execute("prop.get", json!({"layer": l, "path": "missing"})).unwrap_err().to_string(); + assert!(err.contains("no property `missing`"), "{err}"); +} + +#[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()); +} + +#[test] +fn new_text_rejects_malformed_positions_without_creating_a_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap(); + for position in [json!([]), json!([10]), json!(["x", 20]), json!([null, 20])] { + let err = s.execute("layer.newText", json!({"text": "Hello", "position": position})).unwrap_err(); + assert!(err.to_string().contains("position: [x, y]"), "{err}"); + assert!(s.active_comp().unwrap().layers.is_empty()); + } + s.execute("layer.newText", json!({"name": "Valid", "text": "Hello", "position": [10, 20]})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 0.0])); + s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 2); +} + +/// The active comp and the open comp tabs are saved with the project and restored on open (#748). +#[test] +fn reopening_a_project_restores_its_active_comp() { + use effectcraft_project::ItemId; + let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("view.ecproj").to_string_lossy().to_string(); + let mut s = Session::default(); + let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap(); + let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap(); + let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.open", json!({"comp": "CompB"})).unwrap(); + s.state.open_comps.retain(|id| id.0 != c); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(s2.state.active_comp, Some(ItemId(b))); + assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]); + assert!(!s2.is_dirty()); + + // A file from before the view was saved opens on its first comp, as it always did. + let text = std::fs::read_to_string(&path).unwrap(); + let mut old: serde_json::Value = serde_json::from_str(&text).unwrap(); + let obj = old.as_object_mut().unwrap(); + obj.remove("activeComp"); + obj.remove("openComps"); + std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); + let mut s3 = Session::default(); + s3.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)])); + + // Ids that no longer name a comp (edited by hand, or written by another tool) fall back. + obj_set(&mut old, json!(999), json!([999, b, "x"])); + std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); + let mut s4 = Session::default(); + s4.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)])); + obj_set(&mut old, json!(999), json!([])); + std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap(); + let mut s5 = Session::default(); + s5.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)])); + + // XML copies carry it too. + let xml = dir.join("view.ecprojx").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": xml})).unwrap(); + let mut s6 = Session::default(); + s6.execute("file.open", json!({"path": xml})).unwrap(); + assert_eq!(s6.state.active_comp, Some(ItemId(b))); + let _ = std::fs::remove_dir_all(dir); + + fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) { + let o = v.as_object_mut().unwrap(); + o.insert("activeComp".into(), active); + o.insert("openComps".into(), open); + } +} + +/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the +/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714). +#[test] +fn reopening_a_project_restores_layer_times_and_current_time() { + use effectcraft_project::ItemId; + use effectcraft_time::{FrameRate, Tick}; + let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("times.ecproj").to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap(); + s.execute("layer.newSolid", json!({"name": "crown"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + comp.frame_rate = FrameRate::FPS_59_94; + // The layer from the issue: 34f, 315f and 3033f at 59.94 fps. + let l = comp.layers.first_mut().unwrap(); + (l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)); + s.execute("time.set", json!({"time": 6.0})).unwrap(); + let t = s.time(); + // A second comp keeps its own time. + let b = ItemId(s.execute("comp.new", json!({"name": "B"})).unwrap()["comp"].as_u64().unwrap()); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + let tb = s.time(); + s.execute("comp.open", json!({"comp": "C"})).unwrap(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": path})).unwrap(); + let l = s2.project.comp(cid).unwrap().layers.first().unwrap(); + assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800))); + assert_eq!((s2.time(), s2.state.times.get(&b).copied()), (t, Some(tb))); + assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time"); + assert!(!s2.is_dirty()); + + // Hand-edited times past the end or before the start land on the last or first frame. + let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + file["compTimes"] = json!({cid.0.to_string(): i64::MAX, b.0.to_string(): -5}); + std::fs::write(&path, file.to_string()).unwrap(); + let mut s3 = Session::default(); + s3.execute("file.open", json!({"path": path})).unwrap(); + let c = s3.project.comp(cid).unwrap(); + assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); + assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); + assert_eq!(s3.state.times.get(&b).copied(), Some(Tick::ZERO)); +} diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests/settings.rs similarity index 99% rename from crates/engine/src/tests_settings.rs rename to crates/engine/src/tests/settings.rs index c35d4f3a4..620bc3eeb 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -181,7 +181,7 @@ fn every_schema_key_exists_and_docs_list_the_todo_settings() { assert!(ids.contains(&page), "{path:?} opens unknown page {page}"); } } - let docs = include_str!("../../../docs/preferences.md"); + let docs = include_str!("../../../../docs/preferences.md"); for k in crate::prefs::todo_keys() { assert!(docs.contains(&format!("`{k}`")), "docs/preferences.md must list the not-yet-wired setting `{k}`"); } diff --git a/crates/engine/src/tests_shortcut_cmds.rs b/crates/engine/src/tests/shortcut_cmds.rs similarity index 100% rename from crates/engine/src/tests_shortcut_cmds.rs rename to crates/engine/src/tests/shortcut_cmds.rs diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests/stubs.rs similarity index 100% rename from crates/engine/src/tests_stubs.rs rename to crates/engine/src/tests/stubs.rs diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests/styles.rs similarity index 100% rename from crates/engine/src/tests_styles.rs rename to crates/engine/src/tests/styles.rs diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests/tail.rs similarity index 100% rename from crates/engine/src/tests_tail.rs rename to crates/engine/src/tests/tail.rs diff --git a/crates/engine/src/tests_text.rs b/crates/engine/src/tests/text.rs similarity index 99% rename from crates/engine/src/tests_text.rs rename to crates/engine/src/tests/text.rs index 84f88fd33..3980c7bb5 100644 --- a/crates/engine/src/tests_text.rs +++ b/crates/engine/src/tests/text.rs @@ -198,7 +198,7 @@ fn cached_text_presets_keep_the_catalog_and_application_independent() { #[ignore = "warm text-preset listing benchmark"] fn text_preset_listing_benchmark() { let old = || { - let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap(); + let v: serde_json::Value = serde_json::from_str(include_str!("../../presets/text_animators.json")).unwrap(); let presets = v["presets"].as_array().unwrap().clone(); presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::>() }; diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests/text_edit.rs similarity index 100% rename from crates/engine/src/tests_text_edit.rs rename to crates/engine/src/tests/text_edit.rs diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests/three_d.rs similarity index 100% rename from crates/engine/src/tests_3d.rs rename to crates/engine/src/tests/three_d.rs diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests/timeline.rs similarity index 100% rename from crates/engine/src/tests_timeline.rs rename to crates/engine/src/tests/timeline.rs diff --git a/crates/engine/src/tests_timeline_interchange.rs b/crates/engine/src/tests/timeline_interchange.rs similarity index 100% rename from crates/engine/src/tests_timeline_interchange.rs rename to crates/engine/src/tests/timeline_interchange.rs diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests/track.rs similarity index 100% rename from crates/engine/src/tests_track.rs rename to crates/engine/src/tests/track.rs diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests/vector_import.rs similarity index 100% rename from crates/engine/src/tests_vector_import.rs rename to crates/engine/src/tests/vector_import.rs diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests/viewer.rs similarity index 100% rename from crates/engine/src/tests_viewer.rs rename to crates/engine/src/tests/viewer.rs