//! M13.7 menu items: File ▸ Watch Folder, Import ▸ Vanishing Point (disabled), Create Nulls From //! Paths, VR Comp Editor and Help ▸ In-App / Online Tutorials. use std::sync::Arc; use serde_json::json; use crate::rq_tests::MockExporter; use crate::{Event, Session}; fn out_dir(name: &str) -> std::path::PathBuf { let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn watch_folder_renders_queued_projects_and_writes_status() { let dir = out_dir("watch-folder"); let ex = Arc::new(MockExporter { log: Default::default() }); // A project with a queued render, and one (in a subfolder) with nothing queued. let mut a = Session { exporter: Some(ex.clone()), ..Default::default() }; a.execute("comp.new", json!({"name": "Shot", "width": 32, "height": 32, "frameRate": 10, "duration": 0.5})).unwrap(); a.execute("renderQueue.add", json!({})).unwrap(); let pa = dir.join("a.ecproj"); a.execute("file.saveAs", json!({"path": pa.to_string_lossy()})).unwrap(); std::fs::create_dir_all(dir.join("collected")).unwrap(); let mut b = Session::default(); b.execute("comp.new", json!({"name": "Idle", "width": 32, "height": 32, "duration": 1})).unwrap(); let pb = dir.join("collected/b.ecproj"); b.execute("file.saveAs", json!({"path": pb.to_string_lossy()})).unwrap(); let mut host = Session { exporter: Some(ex.clone()), ..Default::default() }; assert!(!host.is_enabled("file.watchFolder.poll")); let r = host.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap(); let done = r["rendered"].as_array().unwrap(); assert_eq!(done.len(), 2, "{r}"); assert_eq!(host.state.watch_folder.as_deref(), Some(dir.to_string_lossy().as_ref())); let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("a.ecproj.status.json")).unwrap()).unwrap(); assert_eq!(st["state"], "done", "{st}"); assert_eq!(st["items"][0]["comp"], "Shot"); assert_eq!(st["items"][0]["status"], "Done"); assert_eq!(ex.log.lock().unwrap().len(), 1); let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("collected/b.ecproj.status.json")).unwrap()).unwrap(); assert_eq!(st["note"], "nothing queued"); // Rendered projects are not picked up again; deleting the status file re-renders. assert!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().is_empty()); std::fs::remove_file(dir.join("a.ecproj.status.json")).unwrap(); assert_eq!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().len(), 1); assert_eq!(ex.log.lock().unwrap().len(), 2); // A broken project fails with a status, not an error. std::fs::write(dir.join("broken.ecproj"), b"not a project").unwrap(); let r = host.execute("file.watchFolder.poll", json!({})).unwrap(); assert_eq!(r["rendered"][0]["state"], "failed"); host.execute("file.watchFolder", json!({"stop": true})).unwrap(); assert!(host.state.watch_folder.is_none()); assert!(host.execute("file.watchFolder", json!({"folder": "/no/such/folder"})).is_err()); } #[test] fn vanishing_point_import_is_disabled_with_a_reason() { let mut s = Session::default(); assert!(!s.is_enabled("file.importVanishingPoint")); let spec = crate::commands::find("file.importVanishingPoint").unwrap(); assert!((spec.enabled)(&s).unwrap_err().contains("public specification")); assert!(s.execute("file.importVanishingPoint", json!({})).is_err()); } #[test] fn help_tutorials() { let mut s = Session::default(); s.drain_events(); s.execute("help.onlineTutorials", json!({})).unwrap(); s.execute("help.inAppTutorials", json!({})).unwrap(); let ev = s.drain_events(); assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE))); assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials"))); } /// A comp with a 2D solid at (50, 40) carrying a square mask. fn masked() -> (Session, u64) { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap(); let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap(); (s, l) } #[test] fn nulls_follow_points_and_points_follow_nulls() { let (mut s, l) = masked(); s.execute("layer.select", json!({"layers": [l]})).unwrap(); let r = s.execute("paths.nullsFollowPoints", json!({})).unwrap(); let nulls: Vec = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); assert_eq!(nulls.len(), 4); let comp = s.active_comp().unwrap().clone(); let n2 = comp.layer(effectcraft_project::LayerId(nulls[2])).unwrap(); assert_eq!(n2.name, "Plate: Mask 1 [3]"); // Placed on the vertex in comp space (the 40×40 solid is centred at (50, 40)). let pos = n2.props.prop("transform/position").unwrap(); assert_eq!(&pos.value.components()[..2], &[70.0, 60.0]); let e = pos.expr.as_ref().unwrap().text.clone(); assert!(e.contains("thisComp.layer(\"Plate\")") && e.contains(".points()[2]"), "{e}"); // One undo step. assert!(s.undo()); assert_eq!(s.active_comp().unwrap().layers.len(), 1); let r = s.execute("paths.pointsFollowNulls", json!({"layer": l, "path": "masks/#1/path"})).unwrap(); assert_eq!(r["nulls"].as_array().unwrap().len(), 4); let comp = s.active_comp().unwrap().clone(); let plate = comp.layer(effectcraft_project::LayerId(l)).unwrap(); let e = plate.props.prop("masks/#1/path").unwrap().expr.as_ref().unwrap().text.clone(); assert!(e.contains("createPath(pts") && e.contains("\"Plate: Mask 1 [1]\"") && e.contains("fromComp"), "{e}"); // Not a path → error. assert!(s.execute("paths.nullsFollowPoints", json!({"layer": l, "path": "transform/opacity"})).is_err()); } #[test] fn trace_path_adds_a_progress_null() { let (mut s, l) = masked(); let r = s.execute("paths.tracePath", json!({"layer": l, "loop": true})).unwrap(); let n = r["null"].as_u64().unwrap(); let comp = s.active_comp().unwrap().clone(); let null = comp.layer(effectcraft_project::LayerId(n)).unwrap(); let slider = null.props.prop("effects/#1/slider").unwrap(); assert_eq!(slider.keys.len(), 2); assert_eq!(slider.keys[1].value.as_f64(), 100.0); assert!(slider.expr.as_ref().unwrap().text.contains("loopOut")); assert_eq!(null.props.group("effects/#1").unwrap().name, "Progress"); assert!(null.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.contains("pointOnPath(effect(\"Progress\")(\"Slider\") / 100)")); } #[test] fn vr_comp_editor_turns_the_face_cameras_together() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Scene", "width": 64, "height": 64, "frameRate": 10, "duration": 1})).unwrap(); s.execute("layer.newSolid", json!({"name": "Wall", "color": "#ff0000"})).unwrap(); s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap(); let envs = s.execute("comp.vr.environments", json!({})).unwrap(); assert_eq!(envs.as_array().unwrap().len(), 1, "{envs}"); let env = &envs[0]; assert_eq!(env["name"], "Scene VR Output"); assert_eq!(env["faces"].as_array().unwrap().len(), 6); let view = env["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!(view.iter().all(|v| v.abs() < 1e-6 || (v - 360.0).abs() < 1e-6), "{view:?}"); let out = env["output"].as_u64().unwrap(); let r = s.execute("comp.vr.setView", json!({"comp": out, "pan": 30.0})).unwrap(); let v = r["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!((v[1] - 30.0).abs() < 1e-6 && v[0].abs() < 1e-6 && v[2].abs() < 1e-6, "{v:?}"); // Every face camera turned: Front by 30° about Y. let front = &env["faces"][4]; let fc = s.project.comp(effectcraft_project::ItemId(front["comp"].as_u64().unwrap())).unwrap(); let cam = fc.layer(effectcraft_project::LayerId(front["camera"].as_u64().unwrap())).unwrap(); let o = cam.props.prop("transform/orientation").unwrap().value.components(); assert!((o[1] - 30.0).abs() < 1e-6, "{o:?}"); // One undo step back to the start. assert!(s.undo()); let envs = s.execute("comp.vr.environments", json!({})).unwrap(); assert!(envs[0]["view"][1].as_f64().unwrap().abs() < 1e-6); // Round trip of the Euler decomposition. for o in [[10.0, 20.0, 30.0], [350.0, 45.0, 5.0], [0.0, 0.0, 270.0]] { let m = effectcraft_geom::Mat4::orientation(effectcraft_geom::vec3(o[0], o[1], o[2])); let e = crate::commands::vr_editor::euler(m); assert!(e.iter().zip(o).all(|(a, b)| (a - b).abs() < 1e-6), "{o:?} → {e:?}"); } }