filmcraft/crates/ui-egui/tests/menus_ui.rs

243 lines
10 KiB
Rust

//! Headless UI tests of the M3.11 menu long tail: Find, Create Search Bin (and its Project panel
//! rows), Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence,
//! Normalize Mix Track, Dynamic Audio Waveforms, Reveal Log Files and the System Compatibility
//! Report, all driven by automation id through the control channel.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_engine::project::ItemId;
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>,
}
impl Driver {
fn new() -> Self {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(s).with_control(rx);
// never hand paths to the real OS opener from a test
app.hooks.open_path = Some(Box::new(|_, _| Ok(())));
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
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 menu(&mut self, id: &str) -> Value {
let r = self.ok("ui.menu.invoke", json!({"id": id}));
self.frames(3);
r
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(3);
}
fn has(&mut self, id: &str) -> bool {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array().unwrap().iter().any(|e| e["id"] == json!(id))
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
fn item_named(&mut self, name: &str) -> u64 {
self.app().session.project.items.values().find(|i| i.name == name).unwrap().id.0
}
fn dialog_open(&mut self) -> bool {
self.app().ui.extras.dialog.is_some()
}
}
#[test]
fn menu_items_toggles_and_help() {
let mut d = Driver::new();
let menus = d.ok("ui.menu.list", json!({}));
let find = |id: &str| menus.as_array().unwrap().iter().find(|m| m["id"] == id).cloned().unwrap_or_else(|| panic!("{id} not in menus"));
assert_eq!(find("view.dynamicAudioWaveforms")["checked"], true);
assert_eq!(find("help.systemCompatibilityReport")["path"], json!(["Help"]));
assert_eq!(find("file.mediaProperties")["path"], json!(["File", "Get Media File Properties for"]));
assert_eq!(find("clip.sceneEditDetection")["label"], "Scene Edit Detection…");
// Dynamic Audio Waveforms toggles
d.menu("view.dynamicAudioWaveforms");
assert!(!d.app().ui.extras.dynamic_waveforms);
let menus = d.ok("ui.menu.list", json!({}));
assert_eq!(menus.as_array().unwrap().iter().find(|m| m["id"] == "view.dynamicAudioWaveforms").unwrap()["checked"], false);
// System Compatibility Report
assert_eq!(d.menu("help.systemCompatibilityReport")["dialog"], "systemReport");
assert!(d.has("systemReport.ok"));
d.click("systemReport.ok");
assert!(!d.dialog_open());
// Reveal Log Files writes this session's log and reveals it
let r = d.menu("help.revealLogFiles");
let path = r["path"].as_str().unwrap().to_string();
assert!(std::fs::read_to_string(&path).unwrap().contains("\"os\""));
assert_eq!(d.app().ui.extras.opened.last(), Some(&path));
let _ = std::fs::remove_file(&path);
// Import from Media Browser needs a Media Browser selection
assert_eq!(d.call("ui.menu.invoke", json!({"id": "file.importFromMediaBrowser"}))["ok"], false);
}
#[test]
fn find_dialog_and_search_bins() {
let mut d = Driver::new();
d.ok("ui.set", json!({"focused": "Project"}));
assert_eq!(d.menu("edit.find")["dialog"], "find");
for id in
["find.scope.project", "find.row.0.column", "find.row.0.operator", "find.row.0.text", "find.row.1.text", "find.match.any", "find.ok", "find.cancel"]
{
assert!(d.has(id), "{id}");
}
d.ok(
"ui.set",
json!({"menuDialog": {"rows": [{"column": "Name", "operator": "beginsWith", "text": "Neon"}, {"column": "Name", "operator": "contains", "text": ""}]}}),
);
d.click("find.ok");
let neon = d.item_named("Neon_Loop.mov");
assert_eq!(d.app().session.state.project_selection, vec![ItemId(neon)]);
assert!(d.dialog_open(), "Find stays open until Done");
d.click("find.cancel");
assert!(!d.dialog_open());
// timeline Find from the Timeline panel
d.ok("ui.set", json!({"focused": "Timeline"}));
d.menu("edit.find");
assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().params["scope"], "timeline");
d.ok("ui.set", json!({"menuDialog": {"rows": [{"column": "Name", "operator": "contains", "text": "forest"}]}}));
d.click("find.ok");
assert_eq!(d.app().session.state.selection.len(), 1);
d.click("find.cancel");
// Create Search Bin, shown in the Project panel's list view with its live contents
d.app().ui.project_view = filmcraft_ui_egui::state::ProjectView::List;
d.ok("ui.panel.show", json!({"panel": "Project"}));
d.menu("file.newSearchBin");
assert!(d.has("searchBin.text") && d.has("searchBin.column"));
d.ok("ui.set", json!({"menuDialog": {"text": "ocean", "name": "Ocean shots"}}));
d.click("searchBin.ok");
let bin = d.app().session.project.search_bins[0].id.0;
d.frames(3);
assert!(d.has(&format!("project.searchBin.{bin}")));
d.click(&format!("project.searchBin.{bin}"));
let ocean = d.item_named("Ocean_Sunset.mp4");
assert!(d.app().ui.expanded_bins.contains(&bin));
let items = d.ok("ui.elements", json!({"prefix": format!("project.item.{ocean}")}));
assert!(items.as_array().unwrap().len() >= 2, "listed in its bin and in the search bin: {items}");
}
#[test]
fn project_settings_dialog() {
let mut d = Driver::new();
assert_eq!(d.menu("file.projectSettings.scratchDisks")["dialog"], "projectSettings");
assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().params["tab"], "scratchDisks");
for id in ["projectSettings.scratch.captured", "projectSettings.scratch.videoPreviews.browse", "projectSettings.scratch.autoSave.same"] {
assert!(d.has(id), "{id}");
}
d.click("projectSettings.tab.general");
for id in ["projectSettings.renderer", "projectSettings.videoDisplay", "projectSettings.titleSafe.horizontal", "projectSettings.captureFormat"] {
assert!(d.has(id), "{id}");
}
d.ok(
"ui.set",
json!({"menuDialog": {"titleSafe": [15.0, 12.0], "renderer": filmcraft_engine::project_tools::RENDERER_SOFTWARE, "captured": "/tmp/fc-captured"}}),
);
d.click("projectSettings.tab.ingest");
assert!(d.has("projectSettings.ingest.enabled"));
d.click("projectSettings.ok");
assert!(!d.dialog_open());
let st = d.app().session.project.settings.clone();
assert_eq!(st.title_safe, (15.0, 12.0));
assert_eq!(st.renderer, filmcraft_engine::project_tools::RENDERER_SOFTWARE);
assert_eq!(st.scratch.captured.as_deref(), Some("/tmp/fc-captured"));
assert!(filmcraft_ui_egui::panels::menu_dialogs::software_renderer(d.app()));
}
#[test]
fn clip_and_sequence_dialogs() {
let mut d = Driver::new();
// Scene Edit Detection needs a selected video clip
assert_eq!(d.call("ui.menu.invoke", json!({"id": "clip.sceneEditDetection"}))["ok"], false);
let c = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].id.0;
d.exec("timeline.select", json!({"clips": [c]}));
d.menu("clip.sceneEditDetection");
for id in ["sceneDetect.applyCuts", "sceneDetect.createSubclips", "sceneDetect.generateMarkers", "sceneDetect.sensitivity", "sceneDetect.ok"] {
assert!(d.has(id), "{id}");
}
d.click("sceneDetect.cancel");
assert!(!d.dialog_open());
// Normalize Mix Track
d.menu("sequence.normalizeMixTrack");
d.ok("ui.set", json!({"menuDialog": {"db": -6.0}}));
d.click("normalizeMix.ok");
assert!(!d.dialog_open());
assert!(d.app().session.active_sequence().unwrap().master_volume_db != 0.0);
// Simplify Sequence
let n = d.app().session.project.items.len();
d.menu("sequence.simplify");
assert!(d.has("simplify.keep.audio") && d.has("simplify.closeGaps"));
d.click("simplify.closeGaps");
d.click("simplify.ok");
assert_eq!(d.app().session.project.items.len(), n + 1);
let active = d.app().session.state.active_sequence.unwrap();
assert_eq!(d.app().session.project.item(active).unwrap().name, "Main Edit (Simplified)");
// Automate to Sequence into a new sequence
d.exec("file.newSequence", json!({"name": "Auto"}));
let ids: Vec<u64> = ["Ocean_Sunset.mp4", "Misty_Forest.mp4"].iter().map(|n| d.item_named(n)).collect();
d.exec("project.select", json!({"items": ids}));
assert_eq!(d.menu("clip.automateToSequence")["dialog"], "automate");
d.click("automate.method.overwrite");
d.click("automate.ordering.selection");
d.click("automate.ok");
assert!(!d.dialog_open());
let q = d.app().session.active_sequence().unwrap().clone();
assert_eq!(q.video_tracks[0].items.iter().map(|i| i.item.0).collect::<Vec<_>>(), ids);
// Get Media File Properties for ▸ Selection shows the probe info
d.exec("project.select", json!({"items": [ids[0]]}));
assert_eq!(d.menu("file.mediaProperties")["dialog"], "properties");
assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().info[0]["video"]["width"], 1920);
d.click("properties.ok");
assert!(!d.dialog_open());
}