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

444 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Headless UI tests of Export mode (M6.5): the settings column and summary, the Preset Manager
//! (search, favourites, save, apply), the export queue panel (send, reorder, cancel, start, retry),
//! the Export button and the header's Quick Export popup.
//!
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render the window offscreen with wgpu and write
//! `export-*.png` there; without it no GPU is needed.
use std::path::{Path, PathBuf};
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<PathBuf>,
dir: PathBuf,
}
impl Driver {
fn new(name: &str) -> Self {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/export-tests").join(format!("ui-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data")).unwrap();
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.export_presets.set_dir(&dir.join("data"));
let (tx, rx) = channel();
let app = FilmcraftApp::new(s).with_control(rx);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(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, dir };
d.frames(4);
d
}
fn path(&self, name: &str) -> String {
self.dir.join(name).to_string_lossy().to_string()
}
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(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 queue(&mut self) -> Vec<Value> {
self.exec("export.queue.list", json!({}))["items"].as_array().unwrap().clone()
}
/// Step frames until `done` holds (the app advances the queue and jobs every frame).
fn wait_until(&mut self, what: &str, mut done: impl FnMut(&mut Self) -> bool) {
let t0 = std::time::Instant::now();
while !done(self) {
assert!(t0.elapsed().as_secs() < 300, "timed out waiting for {what}");
self.frames(2);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn snapshot(&mut self, name: &str) {
let Some(dir) = self.snapshots.clone() else { return };
self.frames(2);
match self.harness.render() {
Ok(img) => {
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("{name}.png"))).unwrap();
}
Err(e) => eprintln!("snapshot {name} skipped: {e}"),
}
}
}
impl Drop for Driver {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn export_mode_settings_and_summary() {
let mut d = Driver::new("settings");
d.ok("ui.set", json!({"mode": "export"}));
d.frames(4);
for id in [
"export.fileName",
"export.location",
"export.preset",
"export.preset.more",
"export.format",
"export.section.video",
"export.section.audio",
"export.section.multiplexer",
"export.video.matchSize",
"export.video.bitrateMode",
"export.video.hardwareEncoding",
"export.video.maxQuality",
"export.audio.sampleRate",
"export.range",
"export.scaling",
"export.summary",
"export.estimate",
"export.sendToQueue",
"export.button",
"export.queue.start",
] {
assert!(d.has(id), "{id}");
}
// the default preset and the file name from the sequence
let st = d.ok("ui.inspect", json!({}))["ui"]["export"].clone();
assert_eq!(st["preset"], filmcraft_engine::export::presets::DEFAULT_PRESET);
assert!(!st["fileName"].as_str().unwrap().is_empty());
// a 4K preset shows in the summary
d.ok("ui.set", json!({"export": {"preset": "YouTube 2160p 4K Ultra HD"}}));
d.frames(3);
let sum = d.ok("ui.elements", json!({"prefix": "export.summary"}))[0]["label"].as_str().unwrap().to_string();
assert!(sum.contains("3840x2160"), "{sum}");
// editing a setting turns the preset into Custom
d.click("export.video.maxQuality");
// collapse Video and Audio so Effects is on screen
d.click("export.section.video");
d.click("export.section.audio");
d.click("export.section.effects");
assert_eq!(d.app().ui.export.preset, "Custom");
assert!(d.app().ui.export.settings.max_render_quality);
// Effects section widgets
assert!(d.has("export.effects.loudness") && d.has("export.effects.timecodeOverlay"));
d.click("export.effects.loudness");
assert!(d.app().ui.export.settings.effects.loudness.enabled);
assert!(d.has("export.effects.loudness.target"));
d.snapshot("export-mode");
}
#[test]
fn the_status_bar_and_the_queue_show_the_time_left() {
use std::sync::atomic::Ordering;
use std::time::Duration;
use filmcraft_engine::Job;
use filmcraft_engine::export_tools::QueueStatus;
let mut d = Driver::new("eta");
d.ok("ui.set", json!({"mode": "export"}));
d.frames(3);
d.exec(
"export.queue.add",
json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": d.path("eta.wav"), "range": "custom", "startSeconds": 0.0, "endSeconds": 0.25}),
);
// a job at 25 %, doing 50 units a second: 750 left, 15 s (readings from the near future, because
// the app reads the real clock, which is then behind them)
let job = Job { id: 900, label: "Exporting".into(), progress: Default::default(), result: Default::default() };
job.progress.total.store(1000, Ordering::Relaxed);
let t0 = web_time::Instant::now() + Duration::from_secs(60);
for i in 0..=50u64 {
job.progress.done.store(i * 5, Ordering::Relaxed);
job.progress.eta_at(t0 + Duration::from_millis(i * 100));
}
d.app().session.jobs.push(job);
{
let item = &mut d.app().session.export_queue.items[0];
item.status = QueueStatus::Encoding;
item.job = Some(900);
}
d.frames(4);
// the status bar says it next to the percentage, and agents read it from the same label
let label = d.ok("ui.elements", json!({"prefix": "status.job.progress"}))[0]["label"].as_str().unwrap().to_string();
assert_eq!(label, "25% · 15 s left");
// the queue item has it too
let item = d.queue().into_iter().next().unwrap();
assert!((item["etaSeconds"].as_f64().unwrap() - 15.0).abs() < 0.5, "{item}");
assert_eq!(item["status"], "encoding");
d.snapshot("export-eta");
}
#[test]
fn h265_shows_the_h264_family_controls_without_the_h264_only_ones() {
use filmcraft_engine::export::{ExportSettings, Format};
let mut d = Driver::new("h265");
d.ok("ui.set", json!({"mode": "export"}));
d.frames(4);
// H.264, the default: profile, level and the hardware checkbox
for id in ["export.video.profile", "export.video.level", "export.video.hardwareEncoding", "export.video.bitrateMode"] {
assert!(d.has(id), "H.264: {id}");
}
d.app().ui.export.settings = ExportSettings { format: Format::Hevc, ..Default::default() };
d.frames(4);
for id in [
"export.video.bitrateMode",
"export.video.target",
"export.video.max",
"export.video.keyframeOn",
"export.section.multiplexer",
"export.section.audio",
"export.audio.sampleRate",
] {
assert!(d.has(id), "H.265: {id}");
}
// Main is its only profile, the level is the encoder's choice, and hardware is the only encoder
for id in ["export.video.profile", "export.video.level", "export.video.hardwareEncoding"] {
assert!(!d.has(id), "H.265 has no {id}");
}
let sum = d.ok("ui.elements", json!({"prefix": "export.summary"}))[0]["label"].as_str().unwrap().to_string();
assert!(sum.contains("HEVC Main") && sum.contains("Target 20.00 Mbps"), "{sum}");
d.snapshot("export-h265");
}
#[test]
fn preset_manager_search_favourite_save_apply() {
let mut d = Driver::new("manager");
d.ok("ui.set", json!({"mode": "export"}));
d.frames(3);
d.click("export.preset.more");
assert!(d.app().ui.export.manager.is_some(), "Preset Manager opens");
for id in ["presetManager.search", "presetManager.favoritesOnly", "presetManager.ok", "presetManager.cancel", "presetManager.import", "presetManager.save"]
{
assert!(d.has(id), "{id}");
}
// search
d.ok("ui.set", json!({"export": {"manager": {"query": "prores"}}}));
d.frames(3);
assert!(d.has("presetManager.item.Apple ProRes 422 LT"));
assert!(!d.has("presetManager.item.YouTube 1080p Full HD"), "filtered out");
// favourite with the star
d.click("presetManager.favorite.Apple ProRes 422 LT");
assert!(d.app().session.export_presets.is_favorite("Apple ProRes 422 LT"));
d.ok("ui.set", json!({"export": {"manager": {"query": "", "favoritesOnly": true}}}));
d.frames(3);
assert!(d.has("presetManager.item.Apple ProRes 422 LT"));
assert!(!d.has("presetManager.item.Apple ProRes 422 HQ"));
d.snapshot("export-preset-manager");
// save the current settings as a user preset
d.ok("ui.set", json!({"export": {"manager": {"favoritesOnly": false, "saveName": "My Review"}}}));
d.frames(2);
d.click("presetManager.save");
assert!(d.app().session.export_presets.find("My Review").is_some());
assert!(d.dir.join("data/export-presets.json").exists(), "persisted in the data directory");
// select + OK applies
d.ok("ui.set", json!({"export": {"manager": {"query": "422 LT"}}}));
d.frames(2);
d.click("presetManager.item.Apple ProRes 422 LT");
d.click("presetManager.ok");
assert!(d.app().ui.export.manager.is_none(), "closed on OK");
assert_eq!(d.app().ui.export.preset, "Apple ProRes 422 LT");
assert_eq!(d.app().ui.export.settings.format, filmcraft_engine::export::Format::ProRes);
assert_eq!(d.app().ui.export.settings.prores_profile, "lt");
// delete the user preset through the manager
d.click("export.preset.more");
d.ok("ui.set", json!({"export": {"manager": {"selected": "My Review"}}}));
d.frames(2);
d.click("presetManager.delete");
assert!(d.app().session.export_presets.find("My Review").is_none());
d.click("presetManager.cancel");
assert!(d.app().ui.export.manager.is_none());
}
#[test]
fn queue_panel_send_reorder_cancel_start_retry() {
let mut d = Driver::new("queue");
d.ok("ui.set", json!({"mode": "export"}));
let loc = d.dir.to_string_lossy().to_string();
let set = |d: &mut Driver, name: &str| {
d.ok(
"ui.set",
json!({"export": {"preset": "Waveform Audio 48 kHz 16-bit", "location": loc, "fileName": name, "range": "custom", "customStart": 0.0, "customEnd": 0.25}}),
);
d.frames(2);
d.click("export.sendToQueue");
};
set(&mut d, "q1");
set(&mut d, "q2");
set(&mut d, "q3");
let ids: Vec<u64> = d.queue().iter().map(|i| i["id"].as_u64().unwrap()).collect();
assert_eq!(ids.len(), 3);
assert_eq!(d.queue()[0]["preset"], "Waveform Audio 48 kHz 16-bit");
d.frames(2);
// reorder: the first one down, the last one up
d.click(&format!("export.queue.item.{}.down", ids[0]));
d.click(&format!("export.queue.item.{}.up", ids[2]));
let order: Vec<u64> = d.queue().iter().map(|i| i["id"].as_u64().unwrap()).collect();
assert_eq!(order, [ids[1], ids[2], ids[0]]);
// cancel the second item before starting
d.click(&format!("export.queue.item.{}.cancel", ids[2]));
assert_eq!(d.queue().iter().find(|i| i["id"] == ids[2]).unwrap()["status"], "cancelled");
d.snapshot("export-queue");
d.click("export.queue.start");
d.wait_until("the queue", |d| !d.app().session.export_queue.is_active());
let st: Vec<(u64, String)> = d.queue().iter().map(|i| (i["id"].as_u64().unwrap(), i["status"].as_str().unwrap().to_string())).collect();
assert_eq!(st, [(ids[1], "done".into()), (ids[2], "cancelled".into()), (ids[0], "done".into())]);
assert!(Path::new(&d.path("q1.wav")).exists() && Path::new(&d.path("q2.wav")).exists());
assert!(!Path::new(&d.path("q3.wav")).exists());
// retry the cancelled one from its row
d.frames(2);
d.click(&format!("export.queue.item.{}.retry", ids[2]));
d.wait_until("the retry", |d| !d.app().session.export_queue.is_active());
assert_eq!(d.queue().iter().find(|i| i["id"] == ids[2]).unwrap()["status"], "done");
assert!(Path::new(&d.path("q3.wav")).exists());
// clear finished
d.frames(2);
d.click("export.queue.clear");
assert!(d.queue().is_empty());
}
#[test]
fn export_button_and_quick_export_popup() {
let mut d = Driver::new("quick");
d.ok("ui.set", json!({"mode": "export"}));
let loc = d.dir.to_string_lossy().to_string();
d.ok("ui.set", json!({"export": {"preset": "Waveform Audio 48 kHz 24-bit", "location": loc, "fileName": "button", "range": "custom", "customStart": 0.0, "customEnd": 0.5}}));
d.frames(2);
d.click("export.button");
let out = d.path("button.wav");
d.wait_until("the export", |_| std::fs::metadata(&out).map(|m| m.len() >= 44 + 24_000 * 6).unwrap_or(false));
// Quick Export: header button → popup with presets → Export
d.exec("markers.markIn", json!({"seconds": 0.0}));
d.exec("markers.markOut", json!({"seconds": 0.2}));
d.click("header.quickExport");
assert!(d.app().ui.export.quick_open);
for id in ["quickExport.path", "quickExport.go", "quickExport.preset.Match Source – Adaptive High Bitrate", "quickExport.preset.YouTube 1080p Full HD"] {
assert!(d.has(id), "{id}");
}
d.snapshot("export-quick");
d.click("quickExport.preset.Match Source – Adaptive Low Bitrate");
d.frames(2);
let quick = d.path("quick.mp4");
d.ok("ui.set", json!({"export": {"quickPath": quick}}));
d.frames(2);
d.click("quickExport.go");
assert!(!d.app().ui.export.quick_open, "closes on Export");
d.wait_until("quick export", |d| d.app().session.jobs.iter().all(|j| j.progress.finished.load(std::sync::atomic::Ordering::Relaxed)));
assert!(Path::new(&quick).exists(), "{quick}");
assert_eq!(d.app().session.export_queue.quick_preset.as_deref(), Some("Match Source – Adaptive Low Bitrate"));
// the popup closes with its Close button too
d.click("header.quickExport");
assert!(d.app().ui.export.quick_open);
d.click("quickExport.close");
assert!(!d.app().ui.export.quick_open);
}
#[test]
fn quick_export_file_name_can_be_typed_and_the_folder_link_opens_the_save_dialog() {
use std::sync::{Arc, Mutex};
let mut d = Driver::new("quick-location");
d.exec("markers.markIn", json!({"seconds": 0.0}));
d.exec("markers.markOut", json!({"seconds": 0.2}));
d.click("header.quickExport");
d.ok("ui.set", json!({"export": {"quickPreset": "Waveform Audio 48 kHz 16-bit"}}));
d.frames(2);
let start = PathBuf::from(&d.app().ui.export.quick_path);
assert_eq!(start.extension().unwrap(), "wav", "{start:?}");
// emptying the file name to retype it keeps what is typed instead of refilling the old name
d.click("quickExport.path");
d.ok("ui.key", json!({"key": "Cmd+A"}));
d.ok("ui.key", json!({"key": "Backspace"}));
d.frames(2);
d.ok("ui.type", json!({"text": "renamed"}));
d.frames(2);
let typed = PathBuf::from(&d.app().ui.export.quick_path);
assert_eq!(typed, start.parent().unwrap().join("renamed.wav"));
// the folder link asks the save dialog, seeded with the whole path; cancelling changes nothing
let asked: Arc<Mutex<Vec<(Vec<String>, String)>>> = Arc::default();
let answer: Arc<Mutex<Option<String>>> = Arc::default();
let (log, reply) = (asked.clone(), answer.clone());
d.app().hooks.pick_save_as = Some(Box::new(move |_, exts, suggested| {
log.lock().unwrap().push((exts.iter().map(|e| e.to_string()).collect(), suggested.to_string()));
reply.lock().unwrap().clone()
}));
d.click("quickExport.location");
assert_eq!(asked.lock().unwrap().clone(), [(vec!["wav".to_string()], typed.to_string_lossy().to_string())]);
assert_eq!(PathBuf::from(&d.app().ui.export.quick_path), typed);
assert!(d.app().ui.export.quick_open, "the popup stays open");
// a chosen path is applied and shown; another format's extension follows the preset
*answer.lock().unwrap() = Some(d.path("picked.mp4"));
d.click("quickExport.location");
let picked = d.path("picked.wav");
assert_eq!(d.app().ui.export.quick_path, picked);
let shown = d.ok("ui.elements", json!({"prefix": "quickExport.location"}))[0]["label"].as_str().unwrap().to_string();
assert_eq!(shown, d.dir.to_string_lossy());
d.snapshot("export-quick-location");
d.click("quickExport.go");
d.wait_until("quick export", |d| d.app().session.jobs.iter().all(|j| j.progress.finished.load(std::sync::atomic::Ordering::Relaxed)));
assert!(Path::new(&d.path("picked.wav")).exists(), "{}", d.app().ui.status);
}
#[test]
fn export_mode_file_name_can_be_emptied_and_retyped() {
let mut d = Driver::new("file-name");
d.ok("ui.set", json!({"mode": "export"}));
d.frames(3);
d.click("export.fileName");
d.ok("ui.key", json!({"key": "Cmd+A"}));
d.ok("ui.key", json!({"key": "Backspace"}));
d.frames(2);
d.ok("ui.type", json!({"text": "Final"}));
d.frames(2);
assert_eq!(d.app().ui.export.file_name, "Final");
}