1131 lines
52 KiB
Rust
1131 lines
52 KiB
Rust
//! Headless UI tests of the M12.7 Project panel and Media Browser: List view (columns, sorting,
|
||
//! resizing, inline rename), Icon view (hover scrub sets In / Out, thumbnail size), Freeform view
|
||
//! (dragging places a card, stacks), the panel menu and Metadata Display… dialog, view presets,
|
||
//! bins opened in place / in a tab / in a window, and the Media Browser (navigation, Favorites,
|
||
//! Edit Columns…, import, drag to the Project panel, Open In Source Monitor).
|
||
//!
|
||
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write
|
||
//! `project-*.png` / `media-browser-*.png`.
|
||
|
||
#![allow(dead_code)]
|
||
|
||
use std::sync::mpsc::{Sender, channel};
|
||
|
||
use egui_kittest::Harness;
|
||
use filmcraft_engine::Session;
|
||
use filmcraft_engine::project_panel::{FREEFORM_POS, FREEFORM_STACK, ViewMode};
|
||
use filmcraft_project::{BinEntry, 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>,
|
||
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 rect(&mut self, id: &str) -> [f32; 4] {
|
||
let v = self.ok("ui.elements", json!({"prefix": id}));
|
||
let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone();
|
||
let r = &e["rect"];
|
||
[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]
|
||
}
|
||
|
||
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.
|
||
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 key(&mut self, key: &str) {
|
||
self.ok("ui.key", json!({"key": key}));
|
||
self.frames(2);
|
||
}
|
||
|
||
fn app(&mut self) -> &mut FilmcraftApp {
|
||
self.harness.state_mut()
|
||
}
|
||
|
||
/// The demo's Footage bin and its media items.
|
||
fn footage(&mut self) -> (u64, Vec<u64>) {
|
||
let p = &self.app().session.project;
|
||
let bin = p
|
||
.root
|
||
.children
|
||
.iter()
|
||
.find_map(|e| match e {
|
||
BinEntry::Bin(b) if b.name == "Footage" => Some(b.clone()),
|
||
_ => None,
|
||
})
|
||
.expect("Footage bin");
|
||
let items = bin.children.iter().filter_map(|e| if let BinEntry::Item(i) = e { Some(i.0) } else { None }).collect();
|
||
(bin.id.0, items)
|
||
}
|
||
|
||
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
|
||
let Some(dir) = self.snapshots.clone() else { return };
|
||
let crop = prefix.map(|p| self.rect(p));
|
||
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([x, y, w, h]) => {
|
||
let (x0, y0) = ((x * ppp).max(0.0) as u32, (y * ppp).max(0.0) as u32);
|
||
let (x1, y1) = ((((x + w) * ppp) as u32).min(img.width()), (((y + h) * 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());
|
||
}
|
||
}
|
||
|
||
/// The demo project with the Project panel maximized (room for the List view's columns).
|
||
fn project_driver() -> Driver {
|
||
let mut d = Driver::demo();
|
||
d.ok("ui.panel.show", json!({"panel": "Project"}));
|
||
d.ok("ui.set", json!({"focused": "Project"}));
|
||
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
|
||
d.frames(3);
|
||
d
|
||
}
|
||
|
||
/// Double-click a bin (synthetic input runs one event per frame, too slow for a double-click, so
|
||
/// this sends what the double-click does: open as Settings ▸ General ▸ Bins says).
|
||
fn double_click_bin(d: &mut Driver, bin: u64, alt: bool) {
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "alt": alt}}));
|
||
d.frames(3);
|
||
}
|
||
|
||
#[test]
|
||
fn list_view_columns_sort_resize_and_rename() {
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
let (bin, items) = d.footage();
|
||
d.frames(2);
|
||
// expand the Footage bin
|
||
d.click(&format!("project.bin.{bin}.toggle"));
|
||
assert!(d.app().ui.expanded_bins.contains(&bin));
|
||
d.wait_for(&format!("project.item.{}", items[0]));
|
||
for c in ["Name", "Frame Rate", "Media Start", "Media Duration"] {
|
||
assert!(d.has(&format!("project.list.header.{c}")), "{c} header");
|
||
}
|
||
assert!(d.has(&format!("project.item.{}.label", items[0])), "label chip");
|
||
let y = |d: &mut Driver, i: u64| d.rect(&format!("project.item.{i}"))[1];
|
||
// Name ascending: rows ordered by name
|
||
let mut by_name: Vec<(String, u64)> = items.iter().map(|i| (d.app().session.project.item(ItemId(*i)).unwrap().name.to_lowercase(), *i)).collect();
|
||
by_name.sort();
|
||
let (first, last) = (by_name[0].1, by_name.last().unwrap().1);
|
||
assert!(y(&mut d, first) < y(&mut d, last));
|
||
d.snapshot("project-list", Some("panel.Project"));
|
||
// clicking the Name header reverses the order
|
||
d.click("project.list.header.Name");
|
||
assert!(d.app().session.prefs.project_panel.view.sort.descending);
|
||
d.frames(2);
|
||
assert!(y(&mut d, first) > y(&mut d, last));
|
||
// drag the Frame Rate column wider (saved in preferences)
|
||
let w0 = d.app().session.prefs.project_panel.view.columns.iter().find(|c| c.name == "Frame Rate").unwrap().width;
|
||
let r = d.rect("project.list.resize.Frame Rate");
|
||
let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 60.0, "y": cy}}));
|
||
d.frames(3);
|
||
let w1 = d.app().session.prefs.project_panel.view.columns.iter().find(|c| c.name == "Frame Rate").unwrap().width;
|
||
assert!(w1 > w0 + 30.0, "{w0} → {w1}");
|
||
// inline rename: Rename, type, Enter (one undo step)
|
||
d.exec("project.select", json!({"items": [items[0]]}));
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.rename"}));
|
||
d.frames(3);
|
||
assert!(d.has("project.rename"));
|
||
d.key("Cmd+A");
|
||
d.ok("ui.type", json!({"text": "Hero shot"}));
|
||
d.frames(2);
|
||
d.key("Enter");
|
||
d.frames(2);
|
||
assert_eq!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot");
|
||
assert!(d.app().ui.project_panel.rename.is_none());
|
||
d.exec("edit.undo", json!({}));
|
||
assert_ne!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot");
|
||
}
|
||
|
||
#[test]
|
||
fn icon_view_hover_scrub_sets_in_and_out() {
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "icon"}));
|
||
let (bin, items) = d.footage();
|
||
// open the Footage bin in place (Settings ▸ Bins ▸ Double-click: Open in place)
|
||
d.click(&format!("project.bin.{bin}"));
|
||
double_click_bin(&mut d, bin, false);
|
||
assert_eq!(d.app().ui.project_panel.bin, Some(bin));
|
||
assert!(d.label("project.breadcrumb").ends_with("/ Footage"));
|
||
let card = format!("project.item.{}", items[0]);
|
||
d.wait_for(&card);
|
||
d.ok("ui.set", json!({"focused": "Project"}));
|
||
// hover a quarter in, press I; three quarters in, press O
|
||
d.ok("ui.move", json!({"id": card, "fx": 0.25, "fy": 0.5}));
|
||
d.frames(2);
|
||
let h = d.app().ui.project_panel.hover.expect("hover scrub");
|
||
assert_eq!(h.item, items[0]);
|
||
d.key("I");
|
||
d.ok("ui.move", json!({"id": card, "fx": 0.75, "fy": 0.5}));
|
||
d.frames(2);
|
||
d.key("O");
|
||
let it = d.app().session.project.item(ItemId(items[0])).unwrap().clone();
|
||
let m = it.as_media().unwrap();
|
||
let (i, o) = (m.mark_in.expect("In set"), m.mark_out.expect("Out set"));
|
||
let dur = it.duration().0 as f64;
|
||
assert!((i.0 as f64 / dur - 0.25).abs() < 0.05, "In at {}", i.0 as f64 / dur);
|
||
assert!((o.0 as f64 / dur - 0.75).abs() < 0.05, "Out at {}", o.0 as f64 / dur);
|
||
d.frames(2);
|
||
assert!(d.has(&format!("project.item.{}.inOut", items[0])), "In/Out bar on the card");
|
||
// the sequence's marks are untouched
|
||
assert!(d.app().session.active_sequence().unwrap().mark_in.is_none());
|
||
d.snapshot("project-icon", Some("panel.Project"));
|
||
// Shift+] / Shift+[ change the thumbnail size
|
||
let s0 = d.app().session.prefs.project_panel.view.icon_size;
|
||
d.key("Shift+]");
|
||
assert!(d.app().session.prefs.project_panel.view.icon_size > s0);
|
||
d.key("Shift+[");
|
||
assert!((d.app().session.prefs.project_panel.view.icon_size - s0).abs() < 1.0);
|
||
// Hover Scrub off: no hover, I marks the sequence again
|
||
d.key("Shift+H");
|
||
assert!(!d.app().session.prefs.project_panel.hover_scrub);
|
||
d.ok("ui.move", json!({"id": card, "fx": 0.5, "fy": 0.5}));
|
||
d.frames(4);
|
||
d.key("I");
|
||
assert!(d.app().session.active_sequence().unwrap().mark_in.is_some());
|
||
// up one level
|
||
d.click("project.up");
|
||
assert_eq!(d.app().ui.project_panel.bin, None);
|
||
}
|
||
|
||
#[test]
|
||
fn freeform_view_places_and_stacks_cards() {
|
||
let mut d = project_driver();
|
||
let (bin, items) = d.footage();
|
||
d.exec("project.view.set", json!({"view": "freeform"}));
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
|
||
let card = format!("project.item.{}", items[0]);
|
||
d.wait_for(&card);
|
||
assert!(d.has("project.freeform"));
|
||
let r = d.rect(&card);
|
||
let canvas = d.rect("project.freeform");
|
||
let from = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||
let to = (from.0 + 140.0, from.1 + 90.0);
|
||
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 16}));
|
||
d.frames(4);
|
||
let pos = d.app().session.project.item(ItemId(items[0])).unwrap().metadata.get(FREEFORM_POS).cloned().expect("card placed");
|
||
let (x, y, _) = filmcraft_engine::project_panel::parse_pos(&pos).unwrap();
|
||
let (ex, ey) = (r[0] - canvas[0] + 140.0, r[1] - canvas[1] + 90.0);
|
||
assert!((x - ex).abs() < 4.0 && (y - ey).abs() < 4.0, "placed at {x},{y}, expected {ex},{ey}");
|
||
d.frames(2);
|
||
let r2 = d.rect(&card);
|
||
assert!((r2[0] - r[0] - 140.0).abs() < 4.0, "card drawn where it was dropped");
|
||
d.snapshot("project-freeform", Some("panel.Project"));
|
||
// the move is one undo step
|
||
d.exec("edit.undo", json!({}));
|
||
assert!(!d.app().session.project.item(ItemId(items[0])).unwrap().metadata.contains_key(FREEFORM_POS));
|
||
// stack two cards from the card menu
|
||
d.exec("project.select", json!({"items": [items[0], items[1]]}));
|
||
d.ok("ui.click", json!({"id": card, "button": "right"}));
|
||
d.frames(2);
|
||
d.click("project.cardMenu.stack");
|
||
assert_eq!(d.app().session.project.item(ItemId(items[1])).unwrap().metadata.get(FREEFORM_STACK).map(String::as_str), Some(items[0].to_string().as_str()));
|
||
d.frames(2);
|
||
assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge");
|
||
// Freeform View Options… from the panel menu
|
||
d.click("panel.menu.Project");
|
||
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
|
||
d.frames(4);
|
||
let item_rect = d.rect("project.menu.freeformOptions");
|
||
let ctx = d.harness.ctx.clone();
|
||
let at = egui::pos2(item_rect[0] + item_rect[2] / 2.0, item_rect[1] + item_rect[3] / 2.0);
|
||
assert!(ctx.content_rect().contains(at), "bottom menu action must be reachable: {item_rect:?}");
|
||
d.snapshot("project-freeform-menu", None);
|
||
d.click("project.menu.freeformOptions");
|
||
let app = d.app();
|
||
let diagnostic = format!(
|
||
"dialog={:?}, controls={:?}",
|
||
app.ui.project_panel.dialog,
|
||
app.auto.elements.iter().filter(|element| element.id.starts_with("projectDialog.")).map(|element| (&element.id, element.rect)).collect::<Vec<_>>()
|
||
);
|
||
assert!(d.has("projectDialog.snap"), "{diagnostic}");
|
||
d.click("projectDialog.snap");
|
||
d.click("projectDialog.ok");
|
||
assert!(d.app().session.prefs.project_panel.freeform.snap);
|
||
assert!(d.app().ui.project_panel.dialog.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn metadata_display_dialog_and_view_presets() {
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
let (bin, _) = d.footage();
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
|
||
d.frames(2);
|
||
// the panel menu lists Premiere's items
|
||
d.click("panel.menu.Project");
|
||
for id in [
|
||
"project.menu.closeProject",
|
||
"project.menu.newBin",
|
||
"project.menu.newSearchBin",
|
||
"project.menu.rename",
|
||
"project.menu.automateToSequence",
|
||
"project.menu.view.List",
|
||
"project.menu.view.Freeform",
|
||
"project.menu.previewArea",
|
||
"project.menu.hoverScrub",
|
||
"project.menu.fontSize",
|
||
"project.menu.refreshSortOrder",
|
||
"project.menu.metadataDisplay",
|
||
"project.menu.saveViewPresetAs",
|
||
"project.menu.restoreViewPreset",
|
||
"project.menu.freeformOptions",
|
||
] {
|
||
assert!(d.has(id), "{id}");
|
||
}
|
||
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
|
||
d.frames(4);
|
||
d.click("project.menu.metadataDisplay");
|
||
d.frames(4);
|
||
assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. })));
|
||
d.click("projectDialog.field.Label");
|
||
// fields further down: narrow the list with the search box
|
||
d.click("projectDialog.filter");
|
||
d.ok("ui.type", json!({"text": "usage"}));
|
||
d.frames(2);
|
||
d.click("projectDialog.field.Video Usage");
|
||
d.click("projectDialog.filter");
|
||
d.key("Cmd+A");
|
||
d.ok("ui.type", json!({"text": "tape"}));
|
||
d.frames(2);
|
||
d.click("projectDialog.field.Tape Name");
|
||
// move Label up next to Name
|
||
for _ in 0..12 {
|
||
if !d.has("projectDialog.up.Label") {
|
||
break;
|
||
}
|
||
d.click("projectDialog.up.Label");
|
||
}
|
||
d.snapshot("project-metadata-display", None);
|
||
d.click("projectDialog.ok");
|
||
let cols: Vec<String> = d.app().session.prefs.project_panel.view.columns.iter().map(|c| c.name.clone()).collect();
|
||
assert_eq!(cols[0], "Name");
|
||
assert_eq!(cols[1], "Label", "{cols:?}");
|
||
assert!(cols.contains(&"Video Usage".to_string()) && !cols.contains(&"Tape Name".to_string()));
|
||
d.frames(2);
|
||
assert!(d.has("project.list.header.Label") && d.has("project.list.header.Video Usage"));
|
||
|
||
// Save As New View Preset (named), change the view, restore it from Manage
|
||
d.click("panel.menu.Project");
|
||
d.click("project.menu.saveViewPresetAs");
|
||
d.ok("ui.set", json!({"projectPanel": {"dialog": {"savePresetAs": {"name": "Logging"}}}}));
|
||
d.frames(2);
|
||
d.click("projectDialog.ok");
|
||
assert_eq!(d.app().session.prefs.project_panel.presets[0].as_ref().unwrap().name, "Logging");
|
||
d.exec("project.view.set", json!({"view": "icon", "fontSize": "large"}));
|
||
d.click("panel.menu.Project");
|
||
d.click("project.menu.manageViewPresets");
|
||
d.click("projectDialog.preset.1");
|
||
d.click("projectDialog.restore");
|
||
assert_eq!(d.app().session.prefs.project_panel.view.mode, ViewMode::List);
|
||
d.click("projectDialog.ok");
|
||
assert!(d.app().ui.project_panel.dialog.is_none());
|
||
// Font Size and Preview Area
|
||
d.exec("project.view.set", json!({"fontSize": "extraLarge", "previewArea": true}));
|
||
let first = d.footage().1[0];
|
||
d.exec("project.select", json!({"items": [first]}));
|
||
d.frames(3);
|
||
assert!(d.has("project.previewArea"));
|
||
d.snapshot("project-list-preview-xl", Some("panel.Project"));
|
||
}
|
||
|
||
#[test]
|
||
fn bins_open_in_place_in_a_tab_or_in_a_window() {
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
let (bin, items) = d.footage();
|
||
d.app().session.prefs.general.bins_double_click = "openNewTab".into();
|
||
d.frames(2);
|
||
double_click_bin(&mut d, bin, false);
|
||
assert_eq!(d.app().ui.project_panel.tabs.len(), 1);
|
||
assert!(d.has("project.tab.0") && d.has("project.tab.project"));
|
||
// the tab shows the bin's items under its own ids
|
||
d.wait_for(&format!("projectBin.0.item.{}", items[0]));
|
||
d.click("project.tab.project");
|
||
assert_eq!(d.app().ui.project_panel.active_tab, None);
|
||
d.click("project.tab.0.close");
|
||
assert!(d.app().ui.project_panel.tabs.is_empty());
|
||
// Opt+double-click: Open in New Window (a floating bin panel)
|
||
double_click_bin(&mut d, bin, true);
|
||
assert!(d.app().ui.project_panel.tabs.first().is_some_and(|t| t.floating), "{:?}", d.app().ui.project_panel.tabs);
|
||
d.wait_for("projectBin.0.window");
|
||
// a second Project panel on the project
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.newPanel"}));
|
||
d.frames(3);
|
||
assert!(d.has("projectBin.1.window"));
|
||
d.snapshot("project-bin-windows", None);
|
||
// inline rename of a bin
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.rename", "params": {"bin": bin}}));
|
||
d.frames(3);
|
||
d.key("Cmd+A");
|
||
d.ok("ui.type", json!({"text": "Camera A"}));
|
||
d.key("Enter");
|
||
let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone();
|
||
assert_eq!(name, "Camera A");
|
||
}
|
||
|
||
#[test]
|
||
fn select_all_takes_only_the_shown_bin() {
|
||
// #456: Cmd+A selected every item in the project, closed bins and other tabs included
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
let (bin, items) = d.footage();
|
||
assert!(!items.is_empty());
|
||
let sorted = |v: &[ItemId]| {
|
||
let mut v: Vec<u64> = v.iter().map(|i| i.0).collect();
|
||
v.sort();
|
||
v
|
||
};
|
||
let mut footage = items.clone();
|
||
footage.sort();
|
||
// the root with the Footage bin closed: none of its items
|
||
d.app().ui.expanded_bins.clear();
|
||
d.frames(2);
|
||
d.key("Cmd+A");
|
||
let sel = d.app().session.state.project_selection.clone();
|
||
assert!(sel.iter().all(|i| !items.contains(&i.0)), "{sel:?}");
|
||
assert!(sel.iter().all(|i| d.app().session.project.root.children.contains(&BinEntry::Item(*i))), "{sel:?}");
|
||
// twirled open in List view, its items are shown and count
|
||
d.app().ui.expanded_bins.push(bin);
|
||
d.frames(2);
|
||
d.key("Cmd+A");
|
||
let sel = d.app().session.state.project_selection.clone();
|
||
assert!(items.iter().all(|i| sel.contains(&ItemId(*i))), "{sel:?}");
|
||
d.key("Cmd+Shift+A");
|
||
assert!(d.app().session.state.project_selection.is_empty());
|
||
// the bin opened in its own tab: exactly its items
|
||
d.app().ui.expanded_bins.clear();
|
||
d.app().session.prefs.general.bins_double_click = "openNewTab".into();
|
||
double_click_bin(&mut d, bin, false);
|
||
assert_eq!(d.app().ui.project_panel.active_tab, Some(0));
|
||
d.key("Cmd+A");
|
||
assert_eq!(sorted(&d.app().session.state.project_selection), footage);
|
||
}
|
||
|
||
#[test]
|
||
fn a_dragged_bin_nests_in_another_and_back_out() {
|
||
// #456: a bin stayed wherever it was created; dragging it onto another bin did nothing
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
let outer = d.exec("file.newBin", json!({"name": "Outer"}))["bin"].as_u64().unwrap();
|
||
let inner = d.exec("file.newBin", json!({"name": "Inner"}))["bin"].as_u64().unwrap();
|
||
d.app().ui.expanded_bins.clear();
|
||
d.frames(3);
|
||
let parent =
|
||
|d: &mut Driver, b: u64| filmcraft_ui_egui::panels::project::parent_bin(&d.app().session.project.root, filmcraft_project::BinId(b)).map(|p| p.0);
|
||
let root = d.app().session.project.root.id.0;
|
||
let drag = |d: &mut Driver, from: [f32; 4], to: (f32, f32)| {
|
||
let start = (from[0] + 80.0, from[1] + from[3] / 2.0);
|
||
d.ok("ui.drag", json!({"from": {"x": start.0, "y": start.1}, "to": {"x": to.0, "y": to.1}, "steps": 12}));
|
||
d.frames(3);
|
||
};
|
||
// onto the Outer row: Inner nests in it
|
||
let (from, to) = (d.rect(&format!("project.bin.{inner}")), d.rect(&format!("project.bin.{outer}")));
|
||
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
|
||
assert_eq!(parent(&mut d, inner), Some(outer));
|
||
// a bin never goes into itself
|
||
d.app().ui.expanded_bins.push(outer);
|
||
d.frames(3);
|
||
let (from, to) = (d.rect(&format!("project.bin.{outer}")), d.rect(&format!("project.bin.{inner}")));
|
||
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
|
||
assert_eq!(parent(&mut d, outer), Some(root));
|
||
assert_eq!(parent(&mut d, inner), Some(outer));
|
||
// onto empty space: back to the bin the view shows
|
||
let from = d.rect(&format!("project.bin.{inner}"));
|
||
let to = centre(d.rect("project.empty"));
|
||
drag(&mut d, from, to);
|
||
assert_eq!(parent(&mut d, inner), Some(root));
|
||
}
|
||
|
||
#[test]
|
||
fn footer_buttons_are_wired() {
|
||
let mut d = project_driver();
|
||
let bins0 = d.app().session.project.root.children.len();
|
||
d.click("project.button.file.newBin");
|
||
assert_eq!(d.app().session.project.root.children.len(), bins0 + 1);
|
||
d.click("project.button.find");
|
||
assert!(d.app().ui.extras.dialog.is_some(), "Find… dialog");
|
||
d.app().ui.extras.dialog = None;
|
||
d.click("project.button.new-item");
|
||
d.wait_for("project.newItem.file.newColorMatte");
|
||
let n0 = d.app().session.project.items.len();
|
||
d.click("project.newItem.file.newColorMatte");
|
||
// the New Color Matte dialog asks for the color first (#29)
|
||
assert!(d.app().ui.extras.dialog.is_some(), "New Color Matte dialog");
|
||
d.click("colorMatte.ok");
|
||
assert_eq!(d.app().session.project.items.len(), n0 + 1);
|
||
d.click("project.button.project.delete");
|
||
assert_eq!(d.app().session.project.items.len(), n0, "Clear removes the selected new matte");
|
||
for v in ["List", "Icon", "Freeform"] {
|
||
d.click(&format!("project.view.{v}"));
|
||
assert_eq!(format!("{:?}", d.app().session.prefs.project_panel.view.mode), v);
|
||
}
|
||
d.click("project.view.Icon");
|
||
assert!(d.has("project.sortIcons"));
|
||
d.click("project.sortIcons");
|
||
d.click("project.sortIcons.Name");
|
||
assert_eq!(d.app().session.prefs.project_panel.view.icon_sort.column, "Name");
|
||
}
|
||
|
||
#[test]
|
||
fn new_bin_asks_for_its_name() {
|
||
// #456: New Bin opens the name field of the new bin instead of leaving it "New Bin"
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
d.click("project.button.file.newBin");
|
||
d.frames(2);
|
||
let bin = d.app().ui.project_panel.rename.as_ref().and_then(|r| r.bin).expect("the new bin's name is being edited");
|
||
assert!(d.has("project.rename"), "the name field is shown");
|
||
d.key("Cmd+A");
|
||
d.ok("ui.type", json!({"text": "Interviews"}));
|
||
d.key("Enter");
|
||
let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone();
|
||
assert_eq!(name, "Interviews");
|
||
// with a name (agents) the bin is named directly and nothing is edited
|
||
let r = d.ok("ui.menu.invoke", json!({"id": "file.newBin", "params": {"name": "B-roll"}}));
|
||
assert!(r["bin"].as_u64().is_some(), "{r}");
|
||
assert!(d.app().ui.project_panel.rename.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn new_bin_then_clear_removes_the_bin() {
|
||
// leftover Project item selection must not keep Clear from removing the new bin
|
||
let mut d = project_driver();
|
||
d.exec("project.view.set", json!({"view": "list"}));
|
||
d.click("project.button.new-item");
|
||
d.wait_for("project.newItem.file.newColorMatte");
|
||
d.click("project.newItem.file.newColorMatte");
|
||
d.click("colorMatte.ok");
|
||
let items0 = d.app().session.project.items.len();
|
||
let bins0 = d.app().session.project.root.children.len();
|
||
assert!(!d.app().session.state.project_selection.is_empty());
|
||
d.click("project.button.file.newBin");
|
||
d.frames(2);
|
||
let bin = d.app().ui.project_panel.selected_bin.expect("the new bin is selected");
|
||
assert!(d.app().session.state.project_selection.is_empty(), "New Bin clears leftover item selection");
|
||
d.click("project.button.project.delete");
|
||
d.frames(2);
|
||
assert!(d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).is_none(), "Clear removes the new bin");
|
||
assert_eq!(d.app().session.project.root.children.len(), bins0);
|
||
assert_eq!(d.app().session.project.items.len(), items0, "clips already in the project stay");
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------ Media Browser
|
||
|
||
fn media_dir(tag: &str) -> std::path::PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("fc-mb-ui-{tag}-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(dir.join("Selects")).unwrap();
|
||
let wav = filmcraft_media::wav::write_wav16(&[0.0; 9600], 2, 48_000);
|
||
for n in ["a-interview.wav", "b-roll.wav"] {
|
||
std::fs::write(dir.join(n), &wav).unwrap();
|
||
}
|
||
std::fs::write(dir.join("Selects").join("pick.wav"), &wav).unwrap();
|
||
std::fs::write(dir.join("readme.txt"), b"not media").unwrap();
|
||
dir
|
||
}
|
||
|
||
#[test]
|
||
fn file_dialog_import_uses_shown_bin_unless_explicit() {
|
||
let dir = media_dir("import-bin");
|
||
let path = dir.join("a-interview.wav").to_string_lossy().into_owned();
|
||
let mut app = FilmcraftApp::new(Session::default());
|
||
app.hooks.pick_files = Some(Box::new(move |_| vec![path.clone()]));
|
||
let parent = app.session.execute("file.newBin", json!({"name": "Footage"})).unwrap()["bin"].as_u64().unwrap();
|
||
let child = app.session.execute("file.newBin", json!({"name": "Selects", "parent": parent})).unwrap()["bin"].as_u64().unwrap();
|
||
let root = app.session.project.root.id.0;
|
||
app.ui.project_panel.tabs.push(filmcraft_ui_egui::panels::project::BinTab { bin: parent, ..Default::default() });
|
||
for (shown, tab, params, expected) in [
|
||
(None, None, json!({}), root),
|
||
(Some(child), None, json!({}), child),
|
||
(Some(child), Some(0), json!({}), parent),
|
||
(Some(child), None, json!({"bin": null}), root),
|
||
(Some(parent), None, json!({"bin": child}), child),
|
||
] {
|
||
app.ui.project_panel.bin = shown;
|
||
app.ui.project_panel.active_tab = tab;
|
||
let result = app.file_dialog("file.import", ¶ms).unwrap();
|
||
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
|
||
let item = ItemId(result["items"][0].as_u64().unwrap());
|
||
let bin = app.session.project.root.find_bin(filmcraft_project::BinId(expected)).unwrap();
|
||
assert!(bin.children.contains(&BinEntry::Item(item)), "shown={shown:?}, tab={tab:?}, params={params}: expected bin {expected}");
|
||
}
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn media_browser_navigation_import_and_columns() {
|
||
let dir = media_dir("nav");
|
||
let d0 = dir.to_string_lossy().to_string();
|
||
let mut d = Driver::with(Session::default());
|
||
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
|
||
d.ok("ui.set", json!({"focused": "MediaBrowser"}));
|
||
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
|
||
d.exec("mediaBrowser.navigate", json!({"path": d0}));
|
||
d.frames(3);
|
||
d.wait_for("mediaBrowser.entry.a-interview.wav");
|
||
assert!(d.has("mediaBrowser.entry.Selects") && !d.has("mediaBrowser.entry.readme.txt"));
|
||
for id in [
|
||
"mediaBrowser.back",
|
||
"mediaBrowser.forward",
|
||
"mediaBrowser.up",
|
||
"mediaBrowser.path",
|
||
"mediaBrowser.fileTypes",
|
||
"mediaBrowser.ingest",
|
||
"mediaBrowser.importAsImageSequence",
|
||
"mediaBrowser.tree.localDrives",
|
||
] {
|
||
assert!(d.has(id), "{id}");
|
||
}
|
||
// double-click a folder, then Back / Forward
|
||
// type a directory into the path field
|
||
d.click("mediaBrowser.path");
|
||
d.key("Cmd+A");
|
||
d.ok("ui.type", json!({"text": format!("{d0}/Selects")}));
|
||
d.key("Enter");
|
||
d.wait_for("mediaBrowser.entry.pick.wav");
|
||
d.click("mediaBrowser.back");
|
||
d.wait_for("mediaBrowser.entry.b-roll.wav");
|
||
d.click("mediaBrowser.forward");
|
||
d.wait_for("mediaBrowser.entry.pick.wav");
|
||
d.click("mediaBrowser.up");
|
||
d.wait_for("mediaBrowser.entry.b-roll.wav");
|
||
// Favorites and Recent Directories in the tree
|
||
d.click("panel.menu.MediaBrowser");
|
||
d.click("mediaBrowser.menu.favorite");
|
||
assert_eq!(d.app().session.prefs.media_browser.favorites, std::slice::from_ref(&d0));
|
||
d.frames(2);
|
||
let fav_name = dir.file_name().unwrap().to_string_lossy().to_string();
|
||
assert!(d.has(&format!("mediaBrowser.tree.favorites.{fav_name}")));
|
||
assert!(d.has("mediaBrowser.tree.recent"));
|
||
// select two files and Import from the panel menu
|
||
d.click("mediaBrowser.entry.a-interview.wav");
|
||
d.ok("ui.click", json!({"id": "mediaBrowser.entry.b-roll.wav", "command": true}));
|
||
d.frames(2);
|
||
assert_eq!(d.app().session.browser.selection.len(), 2);
|
||
assert!(d.label("mediaBrowser.count").starts_with("2 of 2"));
|
||
d.snapshot("media-browser-list", Some("panel.MediaBrowser"));
|
||
d.click("panel.menu.MediaBrowser");
|
||
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
|
||
d.frames(4);
|
||
d.click("mediaBrowser.menu.import");
|
||
assert_eq!(d.app().session.project.items.len(), 2);
|
||
// Shift+O opens the selection in the Source monitor (already imported: no duplicate)
|
||
d.click("mediaBrowser.entry.b-roll.wav");
|
||
d.ok("ui.set", json!({"focused": "MediaBrowser"}));
|
||
d.key("Shift+O");
|
||
assert!(d.app().session.state.source_item.is_some());
|
||
assert_eq!(d.app().session.project.items.len(), 2);
|
||
// Edit Columns…
|
||
d.click("panel.menu.MediaBrowser");
|
||
d.click("mediaBrowser.menu.editColumns");
|
||
d.click("mediaBrowser.columns.Audio Info");
|
||
d.click("mediaBrowser.columns.ok");
|
||
assert!(d.app().session.prefs.media_browser.columns.contains(&"Audio Info".to_string()));
|
||
d.frames(6);
|
||
assert!(d.has("mediaBrowser.header.Audio Info"));
|
||
// thumbnails view and the file-type filter
|
||
d.click("mediaBrowser.view.thumbnails");
|
||
assert_eq!(d.app().session.prefs.media_browser.view, "thumbnails");
|
||
d.wait_for("mediaBrowser.entry.b-roll.wav");
|
||
d.snapshot("media-browser-thumbnails", Some("panel.MediaBrowser"));
|
||
d.exec("mediaBrowser.settings", json!({"fileTypes": "image", "view": "list"}));
|
||
d.frames(3);
|
||
assert!(!d.has("mediaBrowser.entry.b-roll.wav") && d.has("mediaBrowser.entry.Selects"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn media_browser_drag_imports_into_the_project_panel() {
|
||
let dir = media_dir("drag");
|
||
let mut session = Session::default();
|
||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||
let mut d = Driver::with(session);
|
||
// the Editing workspace has Project and Media Browser in one group: put the browser beside it
|
||
d.ok("ui.set", json!({"workspace": "Editing"}));
|
||
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
|
||
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
|
||
d.frames(3);
|
||
d.wait_for("mediaBrowser.entry.b-roll.wav");
|
||
let n0 = d.app().session.project.items.len();
|
||
// dropped on the Media Browser itself: the import is taken back
|
||
let e = d.rect("mediaBrowser.entry.b-roll.wav");
|
||
let from = (e[0] + 40.0, e[1] + e[3] / 2.0);
|
||
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + 30.0, "y": from.1 + 60.0}}));
|
||
d.frames(6);
|
||
assert_eq!(d.app().session.project.items.len(), n0, "dropped nowhere");
|
||
// dropped on the Timeline: imported and placed
|
||
let tl = d.rect("panel.Timeline");
|
||
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20}));
|
||
d.frames(6);
|
||
assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Dragging several selected Media Browser files onto the Timeline (#1360) places every one of
|
||
/// them, whether it was already in the project or is imported by the drag.
|
||
#[test]
|
||
fn media_browser_drag_places_every_selected_file() {
|
||
let dir = media_dir("drag-many");
|
||
let mut session = Session::default();
|
||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||
let mut d = Driver::with(session);
|
||
d.ok("ui.set", json!({"workspace": "Editing"}));
|
||
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
|
||
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
|
||
d.frames(3);
|
||
d.wait_for("mediaBrowser.entry.b-roll.wav");
|
||
let listed = d.exec("mediaBrowser.list", json!({}));
|
||
let path = |name: &str| listed["entries"].as_array().unwrap().iter().find(|e| e["name"] == json!(name)).unwrap()["path"].clone();
|
||
let (a, b) = (path("a-interview.wav"), path("b-roll.wav"));
|
||
// one file is already in the project, the other is imported by the drag
|
||
d.exec("mediaBrowser.import", json!({"paths": [a]}));
|
||
d.exec("mediaBrowser.select", json!({"paths": [a, b]}));
|
||
d.frames(2);
|
||
let n0 = d.app().session.project.items.len();
|
||
// clips named after each dragged file (a drop inside a long clip also splits that one)
|
||
let placed = |d: &mut Driver, name: &str| {
|
||
d.app().session.active_sequence().map_or(0, |q| q.video_tracks.iter().chain(&q.audio_tracks).flat_map(|t| &t.items).filter(|i| i.name == name).count())
|
||
};
|
||
assert_eq!((placed(&mut d, "a-interview.wav"), placed(&mut d, "b-roll.wav")), (0, 0));
|
||
let e = d.rect("mediaBrowser.entry.b-roll.wav");
|
||
let tl = d.rect("panel.Timeline");
|
||
d.ok("ui.drag", json!({"from": {"x": e[0] + 40.0, "y": e[1] + e[3] / 2.0}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20}));
|
||
d.frames(6);
|
||
assert_eq!(d.app().session.project.items.len(), n0 + 1, "only the new file is imported");
|
||
let status = d.app().ui.status.clone();
|
||
assert_eq!((placed(&mut d, "a-interview.wav"), placed(&mut d, "b-roll.wav")), (1, 1), "both files are placed; status: {status}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Dragging one of several selected Project items onto the Timeline places all of them, back to
|
||
/// back, as one undo step; an item outside the selection goes alone.
|
||
#[test]
|
||
fn dragging_a_selection_to_the_timeline_places_every_item() {
|
||
let mut d = Driver::demo();
|
||
let (bin, items) = d.footage();
|
||
assert!(items.len() >= 3);
|
||
d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2}));
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
|
||
let card = |i: u64| format!("project.item.{i}");
|
||
d.wait_for(&card(items[0]));
|
||
let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>()).unwrap_or(0);
|
||
let steps = d.app().session.history.undo.len();
|
||
let drop = json!({"id": "timeline.tracks", "fx": 0.3, "fy": 0.1});
|
||
|
||
// an item that is not selected: one clip
|
||
d.exec("project.select", json!({"items": [items[2]]}));
|
||
d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16}));
|
||
d.frames(4);
|
||
assert_eq!(clips(&mut d), 1, "an unselected item goes alone");
|
||
d.exec("edit.undo", json!({}));
|
||
|
||
// two of the selection: both, back to back, in one undo step
|
||
d.exec("project.select", json!({"items": [items[0], items[1]]}));
|
||
d.frames(2);
|
||
d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16}));
|
||
d.frames(4);
|
||
assert_eq!(clips(&mut d), 2, "the whole selection is placed");
|
||
let mut v1: Vec<(i64, i64)> =
|
||
d.app().session.active_sequence().unwrap().video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect();
|
||
v1.sort();
|
||
assert_eq!(v1[0].1, v1[1].0, "back to back");
|
||
assert_eq!(d.app().session.history.undo.len(), steps + 1, "the drag is one undo step");
|
||
d.exec("edit.undo", json!({}));
|
||
assert_eq!(clips(&mut d), 0);
|
||
}
|
||
|
||
/// Double-clicking empty space in the Project panel opens Import, also between and beside the
|
||
/// Icon view's cards once the project has some. Only the strip under the last row used to answer,
|
||
/// so with a project loaded a double-click in the visible blank space did nothing. Real pointer
|
||
/// input at 60 frames per second, so the two clicks are a real double-click.
|
||
#[test]
|
||
fn double_click_empty_area_imports_with_a_project_loaded() {
|
||
fn driver(demo: bool) -> (Driver, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||
let mut session = Session::default();
|
||
if demo {
|
||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||
}
|
||
let (tx, rx) = channel();
|
||
let mut app = FilmcraftApp::new(session).with_control(rx);
|
||
let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let seen = picks.clone();
|
||
app.hooks.pick_files = Some(Box::new(move |_| {
|
||
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||
Vec::new()
|
||
}));
|
||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||
let mut d = Driver { harness, tx, snapshots: None };
|
||
d.frames(10);
|
||
(d, picks)
|
||
}
|
||
fn double_click(d: &mut Driver, at: egui::Pos2) {
|
||
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
|
||
// a pause first, as a person makes: clicks closer together continue the last gesture
|
||
// (a triple click) instead of starting a double-click
|
||
d.frames(60);
|
||
push(d, egui::Event::PointerMoved(at));
|
||
d.frames(1);
|
||
for _ in 0..2 {
|
||
for pressed in [true, false] {
|
||
push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||
d.frames(1);
|
||
}
|
||
}
|
||
d.frames(3);
|
||
}
|
||
let count = |p: &std::sync::Arc<std::sync::atomic::AtomicUsize>| p.load(std::sync::atomic::Ordering::SeqCst);
|
||
|
||
// an empty project: the whole panel is the empty area
|
||
let (mut d, picks) = driver(false);
|
||
let r = d.rect("project.empty");
|
||
double_click(&mut d, egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0));
|
||
assert_eq!(count(&picks), 1, "empty project");
|
||
|
||
// the demo project, Icon view: the gap between the first two bins, and the space right of the
|
||
// last card in the first row
|
||
let (mut d, picks) = driver(true);
|
||
d.ok("ui.set", json!({}));
|
||
let (a, b) = (d.rect("project.bin.1"), d.rect("project.bin.2"));
|
||
assert!((a[1] - b[1]).abs() < 1.0 && b[0] > a[0] + a[2], "bins 1 and 2 share a row: {a:?} {b:?}");
|
||
double_click(&mut d, egui::pos2((a[0] + a[2] + b[0]) / 2.0, a[1] + a[3] / 2.0));
|
||
assert_eq!(count(&picks), 1, "between two bins");
|
||
let row: Vec<[f32; 4]> = d.ids("project.bin.").iter().map(|id| d.rect(id)).filter(|r| (r[1] - a[1]).abs() < 1.0).collect();
|
||
let last = row.iter().fold(a, |m, r| if r[0] > m[0] { *r } else { m });
|
||
let panel = d.rect("project.count");
|
||
if last[0] + last[2] + 30.0 < panel[0] + panel[2] {
|
||
double_click(&mut d, egui::pos2(last[0] + last[2] + 20.0, last[1] + last[3] / 2.0));
|
||
assert_eq!(count(&picks), 2, "right of the last card");
|
||
}
|
||
// a card still takes its own double-click: the bin opens, Import does not
|
||
double_click(&mut d, egui::pos2(a[0] + a[2] / 2.0, a[1] + a[3] / 2.0));
|
||
let picked = count(&picks);
|
||
assert!(picked <= 2, "double-clicking a bin must not open Import");
|
||
}
|
||
|
||
/// The report: with audio placed in the timeline, double-clicking the Project panel's empty space
|
||
/// no longer opened Import. A new sequence with a tone on A1 (so the panel shows the sequence and
|
||
/// the audio clip as cards); every empty spot opens Import: the strip under the cards and the space
|
||
/// beside them.
|
||
#[test]
|
||
fn double_click_imports_with_audio_in_the_timeline() {
|
||
let mut session = Session::default();
|
||
session.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap();
|
||
let inter: Vec<f32> = (0..48_000).flat_map(|i| [(i as f32 * 0.0576).sin() * 0.5; 2]).collect();
|
||
let bytes: std::sync::Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into();
|
||
let item = filmcraft_engine::commands::import_bytes(&mut session, "/tone.wav", bytes, None).unwrap();
|
||
session.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
|
||
let (tx, rx) = channel();
|
||
let mut app = FilmcraftApp::new(session).with_control(rx);
|
||
let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||
let seen = picks.clone();
|
||
app.hooks.pick_files = Some(Box::new(move |_| {
|
||
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||
Vec::new()
|
||
}));
|
||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||
let mut d = Driver { harness, tx, snapshots: None };
|
||
d.frames(10);
|
||
let double_click = |d: &mut Driver, at: egui::Pos2| {
|
||
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
|
||
d.frames(60);
|
||
push(d, egui::Event::PointerMoved(at));
|
||
d.frames(1);
|
||
for _ in 0..2 {
|
||
for pressed in [true, false] {
|
||
push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||
d.frames(1);
|
||
}
|
||
}
|
||
d.frames(3);
|
||
};
|
||
let count = || picks.load(std::sync::atomic::Ordering::SeqCst);
|
||
let cards: Vec<[f32; 4]> = d.ids("project.item.").iter().filter(|id| id.matches('.').count() == 2).map(|id| d.rect(id)).collect();
|
||
assert!(!cards.is_empty(), "the sequence and the tone show as cards");
|
||
let empty = d.rect("project.empty");
|
||
eprintln!("cards {cards:?}, empty strip {empty:?}");
|
||
double_click(&mut d, egui::pos2(empty[0] + empty[2] / 2.0, empty[1] + empty[3] / 2.0));
|
||
let strip = count();
|
||
let last = cards.iter().fold(cards[0], |m, r| if r[0] > m[0] { *r } else { m });
|
||
double_click(&mut d, egui::pos2(last[0] + last[2] + 40.0, last[1] + last[3] / 2.0));
|
||
let beside = count() - strip;
|
||
assert_eq!((strip, beside), (1, 1), "double-click on (the strip under the cards, the space beside them)");
|
||
}
|
||
|
||
// ---- marquee selection (#578)
|
||
|
||
/// The Footage bin open in place in `view`, with its items' rects (row by row, left to right).
|
||
fn footage_in(d: &mut Driver, view: &str) -> Vec<(u64, [f32; 4])> {
|
||
let (bin, items) = d.footage();
|
||
d.exec("project.view.set", json!({"view": view}));
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
|
||
d.wait_for(&format!("project.item.{}", items[0]));
|
||
let mut rects: Vec<(u64, [f32; 4])> = items.iter().map(|i| (*i, d.rect(&format!("project.item.{i}")))).collect();
|
||
rects.sort_by(|a, b| (a.1[1], a.1[0]).partial_cmp(&(b.1[1], b.1[0])).unwrap());
|
||
rects
|
||
}
|
||
|
||
fn selection(d: &mut Driver) -> Vec<u64> {
|
||
let mut s: Vec<u64> = d.app().session.state.project_selection.iter().map(|i| i.0).collect();
|
||
s.sort_unstable();
|
||
s
|
||
}
|
||
|
||
fn sorted(mut v: Vec<u64>) -> Vec<u64> {
|
||
v.sort_unstable();
|
||
v
|
||
}
|
||
|
||
fn centre(r: [f32; 4]) -> (f32, f32) {
|
||
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
|
||
}
|
||
|
||
fn marquee(d: &mut Driver, from: (f32, f32), to: (f32, f32), shift: bool) {
|
||
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 12, "modifiers": {"shift": shift}}));
|
||
d.frames(3);
|
||
}
|
||
|
||
#[test]
|
||
fn marquee_selects_cards_in_icon_view() {
|
||
let mut d = project_driver();
|
||
let cards = footage_in(&mut d, "icon");
|
||
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
|
||
assert!((a[1] - c[1]).abs() < 1.0, "the first three cards share a row");
|
||
// from the gap above-left of the first card to the middle of the second
|
||
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(b), false);
|
||
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
|
||
// Shift adds
|
||
marquee(&mut d, (c[0] + 4.0, c[1] - 4.0), centre(c), true);
|
||
assert_eq!(selection(&mut d), sorted(vec![i0, i1, i2]));
|
||
// a plain drag replaces
|
||
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(a), false);
|
||
assert_eq!(selection(&mut d), vec![i0]);
|
||
// the gaps between cards are empty space: a click there deselects
|
||
d.ok("ui.click", json!({"x": a[0] - 4.0, "y": a[1] - 4.0}));
|
||
d.frames(2);
|
||
assert!(selection(&mut d).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn escape_cancels_a_marquee_and_keeps_the_selection() {
|
||
let mut d = project_driver();
|
||
let cards = footage_in(&mut d, "icon");
|
||
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
|
||
d.exec("project.select", json!({"items": [i2]}));
|
||
let send = |d: &mut Driver, e: egui::Event| {
|
||
d.harness.input_mut().events.push(e);
|
||
d.frames(1);
|
||
};
|
||
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
|
||
send(&mut d, egui::Event::PointerMoved(from));
|
||
send(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||
for k in 1..=10 {
|
||
send(&mut d, egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
|
||
}
|
||
assert_eq!(selection(&mut d), sorted(vec![i0, i1]), "the selection follows the rectangle");
|
||
send(&mut d, egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||
let end = egui::pos2(centre(c).0, centre(c).1);
|
||
send(&mut d, egui::Event::PointerMoved(end));
|
||
send(&mut d, egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||
d.frames(2);
|
||
assert_eq!(selection(&mut d), vec![i2], "Escape restored the selection the drag began with");
|
||
}
|
||
|
||
#[test]
|
||
fn only_the_left_button_draws_a_marquee() {
|
||
let mut d = project_driver();
|
||
let cards = footage_in(&mut d, "icon");
|
||
let [(i0, a), (_, b)] = [cards[0], cards[1]];
|
||
d.exec("project.select", json!({"items": [i0]}));
|
||
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
|
||
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary] {
|
||
let mut send = |e: egui::Event| {
|
||
d.harness.input_mut().events.push(e);
|
||
d.frames(1);
|
||
};
|
||
send(egui::Event::PointerMoved(from));
|
||
send(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
|
||
for k in 1..=10 {
|
||
send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
|
||
}
|
||
send(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
|
||
d.key("Escape");
|
||
assert_eq!(selection(&mut d), vec![i0], "{button:?} drag left the selection alone");
|
||
assert!(!d.has("project.marquee"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn marquee_selects_rows_in_list_view() {
|
||
let mut d = project_driver();
|
||
let rows = footage_in(&mut d, "list");
|
||
let n = rows.len();
|
||
let (last, before_last) = (rows[n - 1], rows[n - 2]);
|
||
let empty = d.rect("project.empty");
|
||
// from the empty space under the rows up into the second-to-last row
|
||
marquee(&mut d, (empty[0] + 60.0, empty[1] + 20.0), (empty[0] + 60.0, centre(before_last.1).1), false);
|
||
assert_eq!(selection(&mut d), sorted(vec![last.0, before_last.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn marquee_selects_cards_in_freeform_view() {
|
||
let mut d = project_driver();
|
||
let cards = footage_in(&mut d, "freeform");
|
||
let [(i0, a), (i1, b)] = [cards[0], cards[1]];
|
||
assert!((a[1] - b[1]).abs() < 1.0, "the first two cards share a row");
|
||
marquee(&mut d, (a[0] - 6.0, a[1] - 6.0), centre(b), false);
|
||
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
|
||
assert!(!d.app().session.project.item(ItemId(i0)).unwrap().metadata.contains_key(FREEFORM_POS), "the marquee moved no card");
|
||
}
|
||
|
||
/// The Project selection can hold a bin beside items (one id space). Dragging the items still
|
||
/// places them: the bin is left out instead of failing the whole drop.
|
||
#[test]
|
||
fn a_bin_in_the_selection_does_not_stop_the_items_being_placed() {
|
||
let mut d = Driver::demo();
|
||
let (bin, items) = d.footage();
|
||
d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2}));
|
||
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
|
||
d.wait_for(&format!("project.item.{}", items[0]));
|
||
d.exec("project.select", json!({"items": [items[0], bin, items[1]]}));
|
||
d.frames(2);
|
||
d.ok("ui.drag", json!({"from": {"id": format!("project.item.{}", items[0])}, "to": {"id": "timeline.tracks", "fx": 0.3, "fy": 0.1}, "steps": 16}));
|
||
d.frames(4);
|
||
let clips = d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>()).unwrap_or(0);
|
||
assert_eq!(clips, 2, "status: {}", d.app().ui.status);
|
||
}
|
||
|
||
/// Escape during a Media Browser drag (#580) takes the import back, even over the Timeline.
|
||
#[test]
|
||
fn escape_takes_back_a_media_browser_drag() {
|
||
let dir = media_dir("drag-escape");
|
||
let mut session = Session::default();
|
||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||
let mut d = Driver::with(session);
|
||
d.ok("ui.set", json!({"workspace": "Editing"}));
|
||
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
|
||
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
|
||
d.frames(3);
|
||
d.wait_for("mediaBrowser.entry.b-roll.wav");
|
||
let (n0, before) = (d.app().session.project.items.len(), d.app().session.project.to_json());
|
||
let e = d.rect("mediaBrowser.entry.b-roll.wav");
|
||
let tl = d.rect("panel.Timeline");
|
||
let (from, to) = (egui::pos2(e[0] + 40.0, e[1] + e[3] / 2.0), egui::pos2(tl[0] + tl[2] * 0.6, tl[1] + tl[3] * 0.75));
|
||
let mut send = |e: egui::Event| {
|
||
d.harness.input_mut().events.push(e);
|
||
d.frames(1);
|
||
};
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
send(egui::Event::PointerMoved(from));
|
||
send(button(from, true));
|
||
for k in 1..=20 {
|
||
send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 20.0)));
|
||
if k == 10 {
|
||
send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||
}
|
||
}
|
||
send(button(to, false));
|
||
d.frames(6);
|
||
assert_eq!(d.app().session.project.items.len(), n0, "the import was taken back");
|
||
assert_eq!(d.app().session.project.to_json(), before, "nothing was placed on the Timeline");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|