435 lines
19 KiB
Rust
435 lines
19 KiB
Rust
//! Headless UI tests of the M8.9 / M12.6 panels: Lumetri Scopes (layouts, the settings menu,
|
|
//! footer), Metadata (edit a field by typing, undo), Timecode, Events, Progress and the Reference
|
|
//! Monitor, each opened from Window ▸ and driven by automation id through the control channel.
|
|
//!
|
|
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write `panels-*.png`.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc::{Sender, channel};
|
|
|
|
use egui_kittest::Harness;
|
|
use filmcraft_engine::Session;
|
|
use filmcraft_ui_egui::FilmcraftApp;
|
|
use filmcraft_ui_egui::control::ControlRequest;
|
|
use serde_json::{Value, json};
|
|
|
|
struct Driver {
|
|
harness: Harness<'static, FilmcraftApp>,
|
|
tx: Sender<ControlRequest>,
|
|
snapshots: Option<std::path::PathBuf>,
|
|
}
|
|
|
|
impl Driver {
|
|
fn with(session: Session) -> Self {
|
|
let (tx, rx) = channel();
|
|
let app = FilmcraftApp::new(session).with_control(rx);
|
|
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
|
|
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
|
|
if snapshots.is_some() {
|
|
b = b.wgpu();
|
|
}
|
|
let harness = b.build_eframe(move |_cc| app);
|
|
let mut d = Driver { harness, tx, snapshots };
|
|
d.frames(4);
|
|
d
|
|
}
|
|
|
|
fn demo() -> Self {
|
|
let mut session = Session::default();
|
|
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
|
Self::with(session)
|
|
}
|
|
|
|
fn frames(&mut self, n: usize) {
|
|
for _ in 0..n {
|
|
let ctx = self.harness.ctx.clone();
|
|
let mut raw = std::mem::take(self.harness.input_mut());
|
|
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
|
*self.harness.input_mut() = raw;
|
|
self.harness.step();
|
|
}
|
|
}
|
|
|
|
fn call(&mut self, method: &str, params: Value) -> Value {
|
|
let (req, reply) = ControlRequest::new(method, params.clone());
|
|
self.tx.send(req).unwrap();
|
|
for _ in 0..600 {
|
|
self.frames(1);
|
|
if let Ok(v) = reply.try_recv() {
|
|
return v;
|
|
}
|
|
}
|
|
panic!("no reply to {method} {params}");
|
|
}
|
|
|
|
fn ok(&mut self, method: &str, params: Value) -> Value {
|
|
let v = self.call(method, params.clone());
|
|
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
|
v["result"].clone()
|
|
}
|
|
|
|
fn exec(&mut self, command: &str, params: Value) -> Value {
|
|
self.ok("engine.execute", json!({"command": command, "params": params}))
|
|
}
|
|
|
|
fn click(&mut self, id: &str) {
|
|
self.ok("ui.click", json!({"id": id}));
|
|
self.frames(2);
|
|
}
|
|
|
|
fn ids(&mut self, prefix: &str) -> Vec<String> {
|
|
let v = self.ok("ui.elements", json!({"prefix": prefix}));
|
|
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
|
|
}
|
|
|
|
fn has(&mut self, id: &str) -> bool {
|
|
self.ids(id).iter().any(|i| i == id)
|
|
}
|
|
|
|
fn label(&mut self, id: &str) -> String {
|
|
let v = self.ok("ui.elements", json!({"prefix": id}));
|
|
v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default()
|
|
}
|
|
|
|
/// Step frames until `id` is registered (frames render on worker threads).
|
|
fn wait_for(&mut self, id: &str) {
|
|
for _ in 0..400 {
|
|
if self.has(id) {
|
|
return;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
self.frames(1);
|
|
}
|
|
panic!("{id} never appeared: {:?}", self.ids(id.split('.').next().unwrap()));
|
|
}
|
|
|
|
fn app(&mut self) -> &mut FilmcraftApp {
|
|
self.harness.state_mut()
|
|
}
|
|
|
|
fn ui_panels(&mut self) -> Value {
|
|
self.ok("ui.inspect", json!({}))["ui"]["panels"].clone()
|
|
}
|
|
|
|
/// Offscreen render of the window (or the bounding box of the elements under `prefix`).
|
|
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
|
|
let Some(dir) = self.snapshots.clone() else { return };
|
|
let crop = prefix.map(|p| {
|
|
let v = self.ok("ui.elements", json!({"prefix": p}));
|
|
let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
|
|
for e in v.as_array().unwrap() {
|
|
let r = &e["rect"];
|
|
let (x, y, w, h) = (r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32);
|
|
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x + w), bb[3].max(y + h)];
|
|
}
|
|
bb
|
|
});
|
|
self.frames(2);
|
|
let img = match self.harness.render() {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
eprintln!("snapshot {name} skipped: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let ppp = img.width() as f32 / 1600.0;
|
|
let img = match crop {
|
|
Some(bb) => {
|
|
let x0 = ((bb[0] - 24.0) * ppp).max(0.0) as u32;
|
|
let y0 = ((bb[1] - 30.0) * ppp).max(0.0) as u32;
|
|
let x1 = (((bb[2] + 16.0) * ppp) as u32).min(img.width());
|
|
let y1 = (((bb[3] + 46.0) * ppp) as u32).min(img.height());
|
|
image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image()
|
|
}
|
|
None => img,
|
|
};
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join(format!("{name}.png"));
|
|
img.save(&path).unwrap();
|
|
eprintln!("snapshot: {}", path.display());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_panel_opens_from_the_window_menu() {
|
|
let mut d = Driver::demo();
|
|
let menu = d.ok("ui.menu.list", json!({}));
|
|
let window: Vec<String> =
|
|
menu.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Window"])).filter_map(|m| m["label"].as_str().map(str::to_string)).collect();
|
|
for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Project Notes", "Reference Monitor", "Timecode"] {
|
|
assert!(window.iter().any(|w| w == p), "{p} not in Window: {window:?}");
|
|
}
|
|
for (panel, key) in [
|
|
("LumetriScopes", "scopes.wrench"),
|
|
("Metadata", "metadata."),
|
|
("Timecode", "timecode.row.0"),
|
|
("Events", "events.clearAll"),
|
|
("Progress", "progress.showFinished"),
|
|
("ReferenceMonitor", "reference.gang"),
|
|
("ProjectNotes", "notes.text"),
|
|
] {
|
|
d.ok("ui.menu.invoke", json!({"id": format!("window.panel.{panel}")}));
|
|
d.frames(3);
|
|
assert!(d.has(&format!("panel.tab.{panel}")), "{panel} has no tab");
|
|
assert!(!d.ids(key).is_empty(), "{panel}: no {key} widgets");
|
|
assert_eq!(d.ok("ui.inspect", json!({}))["ui"]["focused"], json!(panel));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lumetri_scopes_layouts_menu_and_footer() {
|
|
let mut d = Driver::demo();
|
|
d.ok("ui.set", json!({"workspace": "Color"}));
|
|
d.ok("ui.panel.show", json!({"panel": "LumetriScopes"}));
|
|
// the default: RGB parade, Rec. 709, 8 Bit, Clamp Signal
|
|
d.wait_for("scopes.view.parade");
|
|
assert_eq!(d.label("scopes.space"), "Rec. 709");
|
|
let p = d.ui_panels();
|
|
assert_eq!((p["scopes"]["shown"].clone(), p["scopes"]["scale"].clone(), p["scopes"]["clamp"].clone()), (json!(["parade"]), json!("bits8"), json!(true)));
|
|
d.snapshot("panels-scopes-parade", Some("scopes."));
|
|
// the wrench menu: add the YUV vectorscope, switch the waveform type through its submenu
|
|
d.click("scopes.wrench");
|
|
assert!(d.has("scopes.menu.vectorscopeYuv"), "{:?}", d.ids("scopes.menu"));
|
|
d.click("scopes.menu.vectorscopeYuv");
|
|
d.wait_for("scopes.view.vectorscopeYuv");
|
|
assert!(d.has("scopes.view.parade"));
|
|
// Clamp Signal in the footer
|
|
d.click("scopes.clamp");
|
|
assert_eq!(d.ui_panels()["scopes"]["clamp"], json!(false));
|
|
// all five scopes in a grid, through ui.set; then a preset
|
|
d.ok("ui.set", json!({"panels": {"scopes": {"shown": ["vectorscopeYuv", "vectorscopeHls", "histogram", "parade", "waveform"], "waveformType": "yc", "paradeType": "rgbWhite", "brightness": "bright", "targets": "100"}}}));
|
|
for k in ["vectorscopeYuv", "vectorscopeHls", "histogram", "parade", "waveform"] {
|
|
d.wait_for(&format!("scopes.view.{k}"));
|
|
}
|
|
let rects: Vec<Value> = d.ok("ui.elements", json!({"prefix": "scopes.view."})).as_array().unwrap().iter().map(|e| e["rect"].clone()).collect();
|
|
assert_eq!(rects.len(), 5);
|
|
d.snapshot("panels-scopes-five", Some("scopes."));
|
|
d.click("scopes.wrench");
|
|
d.ok("ui.click", json!({"id": "scopes.menu.presets"}));
|
|
d.frames(3);
|
|
d.wait_for("scopes.menu.preset.5");
|
|
d.click("scopes.menu.preset.5");
|
|
let s = d.ui_panels()["scopes"].clone();
|
|
assert_eq!(s["preset"], json!("Four Scopes (YUV, float, no clamp)"), "{s}");
|
|
assert_eq!((s["scale"].clone(), s["clamp"].clone(), s["paradeType"].clone()), (json!("float"), json!(false), json!("yuv")));
|
|
assert_eq!(s["shown"].as_array().unwrap().len(), 4);
|
|
// colour space: Rec. 601 shows in the footer
|
|
d.ok("ui.set", json!({"panels": {"scopes": {"colorSpace": "rec601"}}}));
|
|
d.frames(3);
|
|
assert_eq!(d.label("scopes.space"), "Rec. 601");
|
|
// the numbers behind it, for agents
|
|
let r = d.exec("scopes.read", json!({"scopes": ["histogram"], "colorSpace": "601", "bins": false}));
|
|
assert_eq!(r["matrix"], "Bt601");
|
|
assert!(r["histogram"]["samples"].as_u64().unwrap() > 1000, "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_panel_edits_a_field_with_undo() {
|
|
let mut d = Driver::demo();
|
|
let item = d.app().session.project.items.values().filter(|i| i.as_media().is_some()).map(|i| i.id).min().unwrap();
|
|
d.app().session.state.project_selection = vec![item];
|
|
d.app().ui.focused = filmcraft_ui_egui::dock::PanelKind::Project;
|
|
d.ok("ui.menu.invoke", json!({"id": "window.panel.Metadata"}));
|
|
d.ok("ui.set", json!({"focused": "Project"}));
|
|
d.frames(3);
|
|
let name = d.app().session.project.item(item).unwrap().name.clone();
|
|
assert_eq!(d.label("metadata.header"), format!("Clip: {name}"));
|
|
for id in [
|
|
"metadata.field.Name",
|
|
"metadata.label",
|
|
"metadata.field.MediaStart",
|
|
"metadata.field.MediaDuration",
|
|
"metadata.field.Scene",
|
|
"metadata.field.LogNote",
|
|
"metadata.field.FilePath",
|
|
"metadata.section.File",
|
|
] {
|
|
assert!(d.has(id), "{id} missing: {:?}", d.ids("metadata."));
|
|
}
|
|
d.snapshot("panels-metadata", Some("metadata."));
|
|
// type into Scene and commit with Enter: one undo step
|
|
let undo0 = d.app().session.history.undo.len();
|
|
d.click("metadata.field.Scene");
|
|
d.ok("ui.type", json!({"text": "42A"}));
|
|
d.frames(2);
|
|
d.ok("ui.key", json!({"key": "Enter"}));
|
|
d.frames(3);
|
|
assert_eq!(d.app().session.project.item(item).unwrap().metadata.get("Scene").map(String::as_str), Some("42A"));
|
|
assert_eq!(d.app().session.history.undo.len(), undo0 + 1);
|
|
assert_eq!(d.label("metadata.field.Scene"), "42A");
|
|
d.exec("edit.undo", json!({}));
|
|
d.frames(2);
|
|
assert!(!d.app().session.project.item(item).unwrap().metadata.contains_key("Scene"));
|
|
// the same edit from an agent
|
|
d.exec("metadata.set", json!({"item": item.0, "field": "Tape Name", "value": "A001"}));
|
|
d.frames(2);
|
|
assert_eq!(d.label("metadata.field.TapeName"), "A001");
|
|
}
|
|
|
|
/// #620: notes typed into Project Notes land in the project; one typing session is one undo step.
|
|
#[test]
|
|
fn project_notes_panel_types_into_the_project_with_undo() {
|
|
let mut d = Driver::demo();
|
|
d.ok("ui.menu.invoke", json!({"id": "window.panel.ProjectNotes"}));
|
|
d.frames(3);
|
|
let undo0 = d.app().session.history.undo.len();
|
|
d.click("notes.text");
|
|
d.ok("ui.type", json!({"text": "Fix logo"}));
|
|
d.frames(2);
|
|
d.ok("ui.key", json!({"key": "Enter"}));
|
|
d.ok("ui.type", json!({"text": "at 1:02"}));
|
|
d.frames(3);
|
|
assert_eq!(d.app().session.project.notes, "Fix logo\nat 1:02");
|
|
assert_eq!(d.app().session.history.undo.len(), undo0 + 1);
|
|
assert_eq!(d.label("notes.text"), "Fix logo\nat 1:02");
|
|
d.exec("edit.undo", json!({}));
|
|
d.frames(2);
|
|
assert_eq!(d.app().session.project.notes, "");
|
|
// an agent writes them, the panel shows them
|
|
d.exec("project.setNotes", json!({"text": "From the script"}));
|
|
d.frames(2);
|
|
assert_eq!(d.label("notes.text"), "From the script");
|
|
}
|
|
|
|
#[test]
|
|
fn timecode_panel_rows_and_modes() {
|
|
let mut d = Driver::demo();
|
|
d.exec("playhead.set", json!({"timecode": "00:00:02:00"}));
|
|
d.ok("ui.menu.invoke", json!({"id": "window.panel.Timecode"}));
|
|
d.frames(3);
|
|
assert_eq!(d.label("timecode.row.0"), "00:00:02:00");
|
|
d.click("timecode.addRow");
|
|
assert_eq!(d.ui_panels()["timecode"]["rows"].as_array().unwrap().len(), 2);
|
|
// the new row is a duration: the sequence's
|
|
let (dur, df, rate) = {
|
|
let q = d.app().session.active_sequence().unwrap();
|
|
(q.duration(), q.settings.drop_frame, q.settings.frame_rate)
|
|
};
|
|
let want = filmcraft_time::format_time(dur, rate, df, filmcraft_time::TimeDisplay::Timecode, 48000);
|
|
assert_eq!(d.label("timecode.row.1"), want);
|
|
// frames display and Remaining through ui.set
|
|
d.ok("ui.set", json!({"panels": {"timecode": {"rows": [{"mode": "current", "display": "Frames"}, {"mode": "remaining"}]}}}));
|
|
d.frames(2);
|
|
let frame = rate.frame_at(d.app().session.playhead());
|
|
assert_eq!(d.label("timecode.row.0"), frame.to_string());
|
|
let rem = filmcraft_time::format_time(dur - d.app().session.playhead(), rate, df, filmcraft_time::TimeDisplay::Timecode, 48000);
|
|
assert_eq!(d.label("timecode.row.1"), rem);
|
|
d.snapshot("panels-timecode", Some("timecode."));
|
|
// right-click menu: Media Time
|
|
d.ok("ui.click", json!({"id": "timecode.row.1", "button": "right"}));
|
|
d.frames(2);
|
|
if d.has("timecode.row.1.mode.media") {
|
|
d.click("timecode.row.1.mode.media");
|
|
assert_eq!(d.ui_panels()["timecode"]["rows"][1]["mode"], json!("media"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn events_and_progress_panels() {
|
|
let mut d = Driver::demo();
|
|
d.exec("events.clear", json!({}));
|
|
d.ok("ui.menu.invoke", json!({"id": "window.panel.Events"}));
|
|
d.frames(2);
|
|
// a failed command shows up as an error row
|
|
let r = d.call("engine.execute", json!({"command": "clip.rename", "params": {}}));
|
|
assert_eq!(r["ok"], json!(false));
|
|
d.frames(2);
|
|
let rows = d.ids("events.row.");
|
|
assert_eq!(rows.len(), 1, "{rows:?}");
|
|
assert!(d.label(&rows[0]).contains("name"), "{}", d.label(&rows[0]));
|
|
d.click(&rows[0]);
|
|
assert!(d.has("events.details"));
|
|
d.snapshot("panels-events", Some("events."));
|
|
// filter: errors only still shows it; Clear All empties the list
|
|
d.ok("ui.set", json!({"panels": {"events": {"level": "error"}}}));
|
|
d.frames(2);
|
|
assert_eq!(d.ids("events.row.").len(), 1);
|
|
d.click("events.clearAll");
|
|
assert!(d.ids("events.row.").is_empty());
|
|
assert!(d.app().session.log.entries.is_empty());
|
|
|
|
// Progress: a running job with a Cancel button
|
|
let job = filmcraft_engine::Job {
|
|
id: 4242,
|
|
label: "Export demo.mp4".into(),
|
|
progress: Arc::new(Default::default()),
|
|
result: Arc::new(std::sync::Mutex::new(None)),
|
|
};
|
|
job.progress.total.store(10, std::sync::atomic::Ordering::Relaxed);
|
|
job.progress.done.store(4, std::sync::atomic::Ordering::Relaxed);
|
|
d.app().session.jobs.push(job.clone());
|
|
d.ok("ui.menu.invoke", json!({"id": "window.panel.Progress"}));
|
|
d.frames(3);
|
|
assert!(d.label("progress.job.4242").contains("40 %"), "{}", d.label("progress.job.4242"));
|
|
d.snapshot("panels-progress", Some("progress."));
|
|
d.click("progress.cancel.4242");
|
|
assert!(job.progress.cancel.load(std::sync::atomic::Ordering::Relaxed));
|
|
*job.result.lock().unwrap() = Some(Err("cancelled".into()));
|
|
d.frames(3);
|
|
assert!(!d.has("progress.cancel.4242"));
|
|
assert!(d.label("progress.job.4242").contains("Cancelled"));
|
|
// the Events panel logged the job
|
|
let ev = d.exec("events.list", json!({}));
|
|
let msgs: Vec<&str> = ev["entries"].as_array().unwrap().iter().filter_map(|e| e["message"].as_str()).collect();
|
|
assert!(msgs.contains(&"Started: Export demo.mp4") && msgs.contains(&"Cancelled: Export demo.mp4"), "{msgs:?}");
|
|
// hide finished jobs
|
|
d.click("progress.showFinished");
|
|
assert!(!d.has("progress.job.4242"));
|
|
}
|
|
|
|
#[test]
|
|
fn reference_monitor_parks_gangs_and_shows_scopes() {
|
|
let mut d = Driver::demo();
|
|
d.ok("ui.menu.invoke", json!({"id": "window.panel.ReferenceMonitor"}));
|
|
d.frames(3);
|
|
assert_eq!(d.label("reference.timecode"), "00:00:00:00");
|
|
d.exec("playhead.set", json!({"timecode": "00:00:03:00"}));
|
|
d.frames(2);
|
|
// parked: the playhead moved, the reference did not
|
|
assert_eq!(d.label("reference.timecode"), "00:00:00:00");
|
|
d.click("reference.matchPlayhead");
|
|
assert_eq!(d.label("reference.timecode"), "00:00:03:00");
|
|
d.click("reference.stepForward");
|
|
assert_eq!(d.label("reference.timecode"), "00:00:03:01");
|
|
// ganged: follows the playhead
|
|
d.click("reference.gang");
|
|
assert_eq!(d.ui_panels()["reference"]["ganged"], json!(true));
|
|
d.exec("playhead.set", json!({"timecode": "00:00:01:00"}));
|
|
d.frames(2);
|
|
assert_eq!(d.label("reference.timecode"), "00:00:01:00");
|
|
d.wait_for("reference.picture");
|
|
d.snapshot("panels-reference-composite", Some("reference."));
|
|
// scopes of the reference frame
|
|
d.ok("ui.set", json!({"panels": {"reference": {"display": "scopes"}}}));
|
|
d.wait_for("reference.scopes.view.vectorscopeYuv");
|
|
d.wait_for("reference.scopes.view.waveform");
|
|
d.snapshot("panels-reference-scopes", Some("reference."));
|
|
}
|
|
|
|
#[test]
|
|
fn scopes_of_colour_bars() {
|
|
let mut s = Session::default();
|
|
let id = s.execute("file.newBarsAndTone", json!({"seconds": 2})).unwrap()["item"].as_u64().unwrap();
|
|
s.execute("file.newSequenceFromClip", json!({"items": [id]})).unwrap();
|
|
let mut d = Driver::with(s);
|
|
d.ok(
|
|
"ui.set",
|
|
json!({"workspace": "Color", "panels": {"scopes": {"shown": ["vectorscopeYuv", "histogram", "parade", "waveform"], "waveformType": "luma"}}}),
|
|
);
|
|
d.ok("ui.panel.show", json!({"panel": "LumetriScopes"}));
|
|
for k in ["vectorscopeYuv", "histogram", "parade", "waveform"] {
|
|
d.wait_for(&format!("scopes.view.{k}"));
|
|
}
|
|
d.snapshot("panels-scopes-bars", Some("scopes."));
|
|
// the numbers: the six 75 % bars sit on their targets
|
|
let r = d.exec("scopes.read", json!({"scopes": ["vectorscopeYuv", "parade"], "columns": 8}));
|
|
let peaks = r["vectorscopeYuv"]["peaks"].as_array().unwrap();
|
|
let angles: Vec<f64> = peaks.iter().take(7).map(|p| p["angleDeg"].as_f64().unwrap()).collect();
|
|
for t in filmcraft_scopes::targets(filmcraft_scopes::Matrix::Bt709, 191.0 / 255.0) {
|
|
let a = filmcraft_scopes::angle_deg(t.cb, t.cr) as f64;
|
|
assert!(angles.iter().any(|x| (x - a).abs() < 1.5), "{} at {a:.1}° not in {angles:?}", t.name);
|
|
}
|
|
}
|