effectcraft/crates/ui-egui/tests/ui_menus.rs

228 lines
10 KiB
Rust

//! The menu bar from the keyboard, and the app's shortcuts waiting while a menu is open (#279)
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Key, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Press and release `key` (one frame each), then let the UI settle.
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// The label of the widget with keyboard focus (the highlighted menu entry).
fn focused(h: &Harness<'_, EffectcraftApp>) -> String {
h.query_by(|n| n.is_focused()).and_then(|n| n.accesskit_node().label()).unwrap_or_default().trim().to_string()
}
fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool {
h.query_by_label_contains(label).is_some()
}
/// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the
/// preview behind the open menu.
#[test]
fn shortcuts_wait_while_a_menu_is_open() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open");
key(&mut h, Key::Space);
assert!(!h.state().playback.playing, "Space didn't start the preview");
// With the menu closed, Space previews again.
key(&mut h, Key::Escape);
assert!(!shown(&h, "Keyboard Shortcuts"), "Escape closed the menu");
key(&mut h, Key::Space);
assert!(h.state().playback.playing, "Space previews with the menus closed");
}
/// Alt focuses the menu bar; Down opens the menu, Up / Down move the highlight, Right opens a
/// submenu and Left closes it, Right on an entry opens the next menu, Escape closes them.
#[test]
fn arrow_keys_drive_the_menu_bar() {
if cfg!(target_os = "macos") {
// (macOS menus are the system's.)
return;
}
let mut h = harness();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::ALT));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
assert_eq!(focused(&h), "File", "Alt focused the menu bar");
key(&mut h, Key::ArrowDown);
assert!(shown(&h, "Open Project"), "Down opened File");
assert!(focused(&h).starts_with("New"), "its first entry is highlighted: {:?}", focused(&h));
key(&mut h, Key::ArrowRight);
assert!(focused(&h).contains("New Project"), "Right opened the New submenu: {:?}", focused(&h));
key(&mut h, Key::ArrowDown);
assert!(focused(&h).contains("New Project from Template"), "Down moved down the submenu: {:?}", focused(&h));
key(&mut h, Key::ArrowLeft);
assert!(!shown(&h, "New Project from Template"), "Left closed the submenu");
assert!(focused(&h).starts_with("New"), "and went back to its entry: {:?}", focused(&h));
key(&mut h, Key::ArrowDown);
assert!(focused(&h).contains("Open Project"), "{:?}", focused(&h));
key(&mut h, Key::ArrowRight);
assert!(!shown(&h, "Open Project"), "Right on an entry left File");
assert!(shown(&h, "Keyboard Shortcuts"), "for Edit");
key(&mut h, Key::ArrowLeft);
assert!(shown(&h, "Open Project"), "Left went back to File");
key(&mut h, Key::Escape);
assert!(!shown(&h, "Open Project"), "Escape closed the menus");
assert!(!h.state().playback.playing, "no key reached the app");
}
/// Up in a menu opened with the pointer highlights its last entry (wrapping around); Enter on a
/// submenu entry opens it with its first entry highlighted, and Enter chooses an entry.
#[test]
fn enter_chooses_the_highlighted_entry() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
key(&mut h, Key::ArrowUp);
if cfg!(target_os = "macos") {
assert!(focused(&h).starts_with("Keyboard Shortcuts"), "Up went to the last entry: {:?}", focused(&h));
key(&mut h, Key::Enter);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "Enter chose it");
} else {
assert!(focused(&h).starts_with("Preferences"), "Up went to the last entry: {:?}", focused(&h));
key(&mut h, Key::Enter);
assert!(focused(&h).starts_with("General"), "Enter opened the submenu: {:?}", focused(&h));
key(&mut h, Key::Enter);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings), "Enter chose General...");
}
assert!(!shown(&h, "Quick Apply"), "and closed the menu");
}
#[test]
fn relink_menu_uses_engine_availability_and_folder_picker_cancel_is_a_noop() {
use std::cell::Cell;
use std::rc::Rc;
let mut app = EffectcraftApp::new(Session::default());
let menu = effectcraft_ui_egui::menus::menu_items(&app);
let entry = menu.iter().find(|e| e.id == "file.relinkFootage").unwrap();
assert_eq!(entry.path, ["File", "Dependencies"]);
assert_eq!(entry.enabled, app.session.is_enabled("file.relinkFootage"));
assert_eq!(entry.enabled, !cfg!(target_arch = "wasm32"));
let picked = Rc::new(Cell::new(false));
let flag = picked.clone();
app.hooks.pick_folder = Some(Box::new(move || {
flag.set(true);
None
}));
let revision = app.session.revision;
let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.relinkFootage", json!({})).unwrap();
assert!(picked.get());
assert!(result.is_null());
assert_eq!(app.session.revision, revision);
assert!(app.session.history.undo.is_empty());
}
#[test]
fn relink_is_reachable_through_the_dependencies_menu() {
use std::cell::Cell;
use std::rc::Rc;
let mut h = harness();
let picked = Rc::new(Cell::new(false));
let flag = picked.clone();
h.state_mut().hooks.pick_folder = Some(Box::new(move || {
flag.set(true);
None
}));
let revision = h.state().session.revision;
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
let dependencies = h.query_by_label_contains("Dependencies").unwrap().rect();
h.input_mut().events.push(Event::PointerMoved(dependencies.center()));
h.run_steps(8);
let relink = h.query_by_label_contains("Relink Missing Footage").unwrap().rect();
if let Some(path) = std::env::var_os("EFFECTCRAFT_RELINK_MENU_SNAPSHOT") {
h.render().unwrap().save(path).unwrap();
}
click_at(&mut h, relink.center());
assert!(picked.get());
assert_eq!(h.state().session.revision, revision);
}
#[test]
fn relink_restores_decoded_pixels_and_invalidates_viewer_content() {
use effectcraft_engine::project::{ItemId, ItemKind};
use effectcraft_engine::render::RenderOpts;
use effectcraft_engine::time::Tick;
let dir = std::env::temp_dir().join(format!("ec-ui-relink-{}", std::process::id()));
std::fs::create_dir_all(dir.join("old")).unwrap();
std::fs::create_dir_all(dir.join("new/deep")).unwrap();
let old = dir.join("old/plate.png");
image::RgbaImage::from_pixel(32, 32, image::Rgba([230, 40, 80, 255])).save(&old).unwrap();
let mut s = effectcraft_host::session();
let item = s.execute("file.import", json!({"paths": [old], "sequence": false})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Relink", "width": 32, "height": 32, "duration": 1})).unwrap();
s.execute("layer.addItem", json!({"item": item})).unwrap();
let cid = s.active_comp_id().unwrap();
let expected = s.render(cid, Tick::ZERO, RenderOpts::default());
let moved = dir.join("new/deep/plate.png");
std::fs::rename(&old, &moved).unwrap();
s.execute("footage.check", json!({"wait": true})).unwrap();
let ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") };
assert!(f.missing);
let missing_content = effectcraft_ui_egui::frames::comp_content(&s.project, cid);
let missing = s.render(cid, Tick::ZERO, RenderOpts::default());
assert_ne!(missing.data, expected.data);
let r = s.execute("file.relinkFootage", json!({"folder": dir.join("new"), "wait": true})).unwrap();
assert_eq!(r["relinked"], 1);
assert_ne!(effectcraft_ui_egui::frames::comp_content(&s.project, cid), missing_content);
assert_eq!(s.render(cid, Tick::ZERO, RenderOpts::default()).data, expected.data);
std::fs::remove_dir_all(dir).unwrap();
}
/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and
/// Avid), which the Media Browser lists and the importer reads.
#[test]
fn import_dialog_offers_mxf() {
use std::cell::RefCell;
use std::rc::Rc;
let mut app = EffectcraftApp::new(Session::default());
let offered = Rc::new(RefCell::new(Vec::<String>::new()));
let seen = offered.clone();
app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| {
*seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect();
Vec::new()
}));
let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap();
assert!(result.is_null(), "cancelled: nothing imported");
let offered = offered.borrow();
assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}");
assert!(offered.iter().any(|e| e == "mov"));
}