diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index f1d28c5e..1111db40 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -623,3 +623,47 @@ fn gpu_adapter_name_empty_is_unset() { let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}"); } + +/// Renders two seconds of the demo at quarter resolution (long enough for several progress +/// updates) and returns stderr, which `output()` reads through a pipe: not a terminal. +fn render_demo_stderr(name: &str, extra: &[&str]) -> String { + let out = tmp(name); + let mut args = vec!["render", "--demo", "--format", "gif", "--start", "0", "--end", "2", "--resolution", "quarter", "--out", out.to_str().unwrap()]; + args.extend_from_slice(extra); + let o = bin().args(&args).output().unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr).to_string(); + assert!(o.status.success(), "{args:?}: {stderr}"); + let _ = std::fs::remove_file(out); + stderr +} + +/// `render --progress` with a piped stderr reports the render's frames as +/// `progress ` lines, one per +/// change (#1778). +#[test] +fn render_progress_reports_frames_on_a_piped_stderr() { + let stderr = render_demo_stderr("progress.gif", &["--progress"]); + let mut last_done = None; + let mut totals = vec![]; + for line in stderr.lines().filter(|l| l.starts_with("progress")) { + let fields: Vec<&str> = line.split(' ').collect(); + assert_eq!(fields.len(), 5, "{line}"); + let done: u64 = fields[1].parse().unwrap_or_else(|e| panic!("{line}: frames done: {e}")); + let total: u64 = fields[2].parse().unwrap_or_else(|e| panic!("{line}: frames total: {e}")); + let elapsed: f64 = fields[3].parse().unwrap_or_else(|e| panic!("{line}: seconds elapsed: {e}")); + assert!(fields[4] == "-" || fields[4].parse::().is_ok_and(|left| left >= 0.0), "{line}: seconds left"); + assert!(done <= total && total > 0 && elapsed >= 0.0, "{line}"); + assert!(last_done.is_none_or(|last| done > last), "one line per change, and frames only go up: {stderr}"); + last_done = Some(done); + totals.push(total); + } + assert!(!totals.is_empty(), "no progress line on a piped stderr: {stderr}"); + assert!(totals.iter().all(|&t| t == totals[0]), "one render, one total: {stderr}"); +} + +/// Without `--progress`, the same render writes no progress lines to a piped stderr (#1778). +#[test] +fn render_without_progress_writes_no_progress_lines() { + let stderr = render_demo_stderr("no-progress.gif", &[]); + assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}"); +} diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index 1dd5874c..8b877755 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -690,10 +690,11 @@ fn template_frame_output_cannot_alias_its_values_through_a_hard_link() { let _ = std::fs::remove_dir_all(&dir); } -#[test] -fn template_values_fill_a_media_replacement_control_with_a_file_path() { +/// A template whose `Clip` control is a Media Replacement showing `source.png` (blue), with +/// `other.png` (red) beside it to replace it with, exported as `lt.ectemplate` in a fresh `dir`. +fn media_template(case: &str) -> PathBuf { let temp_root = std::env::temp_dir().canonicalize().unwrap(); - let dir = temp_root.join(format!("ec-qa-template-media-control-{}", std::process::id())); + let dir = temp_root.join(format!("ec-qa-template-{case}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); @@ -708,11 +709,24 @@ fn template_values_fill_a_media_replacement_control_with_a_file_path() { let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string(); ok(&dir, &["exec", "essential.addMedia", ¶ms, "t.ecproj", "--save"]); ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + dir +} - std::fs::write(dir.join("values.json"), r#"{"Clip":"other.png"}"#).unwrap(); - ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]); +/// Renders the template's frame with `values` and checks that the control shows `other.png`. +fn assert_renders_the_replacement(dir: &PathBuf, values: &str) { + std::fs::write(dir.join("values.json"), values).unwrap(); + ok(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]); let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0; - assert!((px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2, "replacement media rendered: {px:?}"); + assert!( + (px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2, + "{values}: replacement media rendered: {px:?}" + ); +} + +#[test] +fn template_values_fill_a_media_replacement_control_with_a_file_path() { + let dir = media_template("media-control"); + assert_renders_the_replacement(&dir, r#"{"Clip":"other.png"}"#); // A control that is not given a path is rejected with a clear message. std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap(); @@ -721,3 +735,35 @@ fn template_values_fill_a_media_replacement_control_with_a_file_path() { assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}"); let _ = std::fs::remove_dir_all(&dir); } + +/// The object forms `{"path"|"file"|"media": …}` take a file path too, and the control is found +/// by name the way `essential.set` finds it: exactly, then ignoring case (#1787). +#[test] +fn template_media_control_takes_a_path_object_and_a_name_in_any_case() { + let dir = media_template("media-forms"); + for values in [ + r#"{"Clip":{"path":"other.png"}}"#, + r#"{"Clip":{"file":"other.png"}}"#, + r#"{"Clip":{"media":"other.png"}}"#, + r#"{"clip":"other.png"}"#, + r#"{"CLIP":{"path":"other.png"}}"#, + ] { + let _ = std::fs::remove_file(dir.join("out.png")); + assert_renders_the_replacement(&dir, values); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// A media path that cannot be imported fails the render with an error naming the control and +/// the path, and writes no frame (#1787). +#[test] +fn template_media_control_reports_a_path_that_cannot_be_imported() { + let dir = media_template("media-missing"); + std::fs::write(dir.join("missing.json"), r#"{"Clip":"missing.png"}"#).unwrap(); + let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing.json", "--time", "1", "--out", "missing-out.png"]); + assert_eq!(code, 1, "{reply}"); + let error = reply["error"].as_str().unwrap(); + assert!(error.contains("control `Clip`: cannot import missing.png"), "{reply}"); + assert!(!dir.join("missing-out.png").exists(), "{reply}"); + let _ = std::fs::remove_dir_all(&dir); +}