From efa52ddd2c9470d71e9909680eb112a32ddba93f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:16:00 +0000 Subject: [PATCH] refactor(lightcraft-ui-egui): move menubar.rs tests to crates/ui-egui/src/menubar/tests.rs --- crates/ui-egui/src/menubar.rs | 831 +--------------------------- crates/ui-egui/src/menubar/tests.rs | 828 +++++++++++++++++++++++++++ 2 files changed, 829 insertions(+), 830 deletions(-) create mode 100644 crates/ui-egui/src/menubar/tests.rs diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index ba4a8a7c2..5c1bcb9fc 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -1186,833 +1186,4 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti } #[cfg(test)] -mod tests { - use super::*; - - fn app() -> LightcraftApp { - LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) - } - - fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { - nodes.iter().find_map(|n| match n { - MenuNode::Item { id: i, .. } if i == id => Some(n), - MenuNode::Submenu { children, .. } => find(children, id), - _ => None, - }) - } - - /// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. - #[test] - fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { - let mut a = app(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - let bar = menu_bar(&a); - let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; - assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); - assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); - // A hint never assigns the cycle key to another command. - assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); - run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid); - run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); - run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::SquareGrid); - run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); - assert_eq!(a.ui.view, ViewMode::PhotoGrid); - } - - /// #2500: native hints never install a borrowed modifier/function key as a direct-grid - /// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. - #[test] - fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { - let mut keymap = crate::shortcuts::Keymap::new(); - for id in ["view.photoGrid", "view.squareGrid"] { - let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); - assert_eq!(label, "Photo Grid"); - assert_eq!(accelerator, Some("G")); - assert!(!native_menu_delivers(accelerator.unwrap())); - for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { - crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); - let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); - assert_eq!(label, format!("Photo Grid ({text})")); - assert_eq!(accelerator, None, "egui keeps {key}"); - } - } - crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); - let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); - assert_eq!(label, "Photo Grid"); - assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); - } - - /// #2500: the control channel runs the real keyboard path exactly once and menu commands - /// continue to select their named grid after the cycle shortcut is remapped. - #[test] - fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { - let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); - h.app.ui.view = ViewMode::PhotoGrid; - for _ in 0..3 { - h.step(); - } - let timeout = std::time::Duration::from_secs(20); - let press = |h: &mut crate::headless::Headless, params| { - let reply = h.request("ui.key", params, timeout); - assert_eq!(reply["ok"], true, "{reply}"); - }; - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - press(&mut h, json!({"key": "G"})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); - for _ in 0..2 { - let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(h.app.ui.view, ViewMode::SquareGrid); - } - } - - /// #2513 review: moving a direct-grid key to the cycle command leaves the visible model - /// unchanged, but must invalidate the native cache so its old accelerator is removed. - #[test] - fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { - let mut a = app(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); - let before = menu_bar(&a); - let direct = native_menu_structure(&before, &a.ui.settings.keymap); - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - let after = menu_bar(&a); - assert_eq!(before, after, "visible hint text does not expose the ownership change"); - assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); - } - - /// #2500: grid view rows show the separate cycle command's current shortcut. - #[test] - fn grid_menu_shortcut_hints_follow_the_cycle_key() { - let mut a = app(); - let hint = |a: &LightcraftApp, id: &str| { - let bar = menu_bar(a); - let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; - match find(nodes, id).expect("grid row") { - MenuNode::Item { shortcut, .. } => shortcut.clone(), - _ => panic!("grid row must be an item"), - } - }; - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); - } - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); - } - crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); - for id in ["view.photoGrid", "view.squareGrid"] { - assert_eq!(hint(&a, id), None, "{id}"); - } - } - - #[test] - fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { - let mut a = app(); - let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; - let items = view(&a); - assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); - assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); - a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); - assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); - } - - /// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), - /// only while sorting at random, and the direction items are off for a shuffle. - #[test] - fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { - fn sort_children(bar: &[(String, Vec)]) -> Vec { - let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; - view.iter() - .find_map(|n| match n { - MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), - _ => None, - }) - .expect("Sort submenu") - } - let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); - let mut a = app(); - let kids = sort_children(&menu_bar(&a)); - assert_eq!(count(&kids), 1); - assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); - a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); - let kids = sort_children(&menu_bar(&a)); - assert_eq!(count(&kids), 1); - assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); - // no direction is shown as chosen while shuffling - let dir_checked = |kids: &[MenuNode]| { - kids.iter() - .filter_map(|n| match n { - MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), - _ => None, - }) - .collect::>() - }; - assert_eq!(dir_checked(&kids), vec![None, None]); - a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); - assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); - } - - /// File opens with the import entry points, worded as importing (not as adding a sidebar - /// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. - #[test] - fn file_menu_starts_with_the_import_entry_points() { - let bar = menu_bar(&app()); - let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; - let labels: Vec = file - .iter() - .take(3) - .map(|n| match n { - MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), - MenuNode::Submenu { label, .. } => format!("{label} ▸"), - MenuNode::Separator => "---".into(), - }) - .collect(); - assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); - assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let text = serde_json::to_string(&all).unwrap(); - assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); - } - - /// Back Up / Restore Library are the browser build's (its library lives in browser storage): - /// absent from the desktop's menus, present and wired to the host where it provides them. - #[test] - fn library_backup_items_follow_the_host() { - let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; - let mut desktop = app(); - assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); - assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); - let calls = std::rc::Rc::new(std::cell::Cell::new(0)); - let c = calls.clone(); - let services = crate::Services { - backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { - c.set(c.get() + 1); - Ok(json!({"photos": s.catalog.len()})) - })), - restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), - ..Default::default() - }; - let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let bar = all(&web); - for id in ["file.backupLibrary", "file.restoreLibrary"] { - assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); - } - let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); - assert!(r["photos"].as_u64().unwrap() > 0); - assert_eq!(calls.get(), 1); - } - - #[test] - fn bar_follows_the_registry_and_state() { - let mut app = app(); - let bar = menu_bar(&app); - let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); - assert_eq!(titles, MENUS); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - // every engine command with a menu path is reachable (parameterized ones via submenus; items - // that follow the state, like Restore for deleted photos, when it applies) - for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { - assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); - } - for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { - assert!(find(&all, id).is_some(), "{id}"); - } - // no doubled or dangling separators - fn check(v: &[MenuNode]) { - assert_ne!(v.first(), Some(&MenuNode::Separator)); - assert_ne!(v.last(), Some(&MenuNode::Separator)); - for w in v.windows(2) { - assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); - } - for n in v { - if let MenuNode::Submenu { children, .. } = n { - check(children); - } - } - } - bar.iter().for_each(|(_, v)| check(v)); - // live state: undo label, enabled, checked - let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; - assert!(!enabled); - run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); - let bar = menu_bar(&app); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; - assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); - let rated: Vec<_> = all - .iter() - .filter_map(|n| match n { - MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), - _ => None, - }) - .flatten() - .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) - .collect(); - assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); - let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; - assert_eq!(*checked, Some(app.ui.filmstrip)); - } - - #[test] - fn quit_ends_the_file_menu_after_unlisted_entries() { - let app = app(); - // the File menu has registry entries the layout doesn't list (appended as fallbacks) - let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; - let unlisted: Vec = crate::menus::menu_entries(&app) - .into_iter() - .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) - .map(|e| e.id) - .collect(); - assert!(!unlisted.is_empty(), "no fallback File entries to order"); - let bar = menu_bar(&app); - let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; - // Quit is the last item, alone in its separator group - assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); - assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); - // every fallback entry is still reachable, before Quit - for id in &unlisted { - assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); - } - } - - #[test] - fn export_with_preset_submenu() { - let mut app = app(); - let bar = menu_bar(&app); - let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = - file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) - else { - panic!("no Export with Preset submenu") - }; - let presets: Vec<_> = children - .iter() - .filter_map(|n| match n { - MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), - _ => None, - }) - .collect(); - assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); - assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); - // a user preset appears after the built-ins; choosing it exports with its settings - app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); - let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); - let w = written.clone(); - app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { - w.lock().unwrap().push((p.to_string(), b.to_vec())); - Ok(()) - })); - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); - assert_eq!(r["files"][0]["width"], 40, "{r}"); - let w = written.lock().unwrap(); - assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); - assert!(w[0].1.starts_with(b"\x89PNG")); - // remembered (expanded) for Export with Previous, folder included - let last = app.session.last_export.clone().unwrap(); - assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); - // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message - // instead of writing into the working directory - let n = w.len(); - drop(w); - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); - assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); - assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); - // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it - // gets its own error rather than a file written elsewhere (issue #956) - let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); - let e = r.unwrap_err(); - assert!(e.contains("no folder on disk"), "{e}"); - assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); - } - - #[test] - fn select_by_submenu_and_text_prompt() { - let mut app = app(); - let bar = menu_bar(&app); - let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; - let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) - else { - panic!("no Select by submenu") - }; - let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; - let r = run_item(&mut app, id, params.clone()).unwrap(); - assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); - // the one-field prompt runs its command with the typed value - app.session.execute("version.create", &json!({"name": "A"})).unwrap(); - crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); - if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { - *value = " Final ".into(); - } - let d = app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - let id = app.session.active().unwrap(); - assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); - } - - #[test] - fn help_opens_the_docs_and_merge_last_needs_photos() { - let mut app = app(); - let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let o = opened.clone(); - app.services.open_url = Some(Box::new(move |u: &str| { - o.lock().unwrap().push(u.to_string()); - Ok(()) - })); - run_item(&mut app, "app.help", Value::Null).unwrap(); - assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); - // merging with the last settings needs a selection of two or more - let first = app.session.visible()[0].0; - app.session.execute("library.select", &json!({"ids": [first]})).unwrap(); - assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err()); - } - - /// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web, - /// `--memory`) or without a file manager to show it, the item is off and the command says why. - #[test] - fn open_log_folder_reveals_the_hosts_log_file() { - let mut app = app(); - let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); - assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log"); - assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); - let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let s = shown.clone(); - app.services.reveal = Some(Box::new(move |p: &str| { - s.lock().unwrap().push(p.to_string()); - Ok(()) - })); - // a file manager alone is not enough: this session keeps no log - assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); - assert!(shown.lock().unwrap().is_empty()); - let log = "/home/a/.config/lightcraft/logs/lightcraft.log"; - app.services.log_file = Some(log.into()); - assert!(crate::menus::ui_enabled(&app, "app.openLogFolder")); - assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. }))); - let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap(); - assert_eq!(r["path"], log); - assert_eq!(shown.lock().unwrap().as_slice(), [log]); - // the file manager's failure is the command's - app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into()))); - assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager"); - } - - #[test] - fn find_missing_and_locate() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("a")).unwrap(); - std::fs::create_dir_all(dir.join("b")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap(); - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap(); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}"); - // Locate: an explicit file - std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap(); - let id = app.session.catalog.photos().next().unwrap().id; - app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); - run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap(); - let p = app.session.catalog.photo(id).unwrap(); - assert_eq!(p.file_name, "one-renamed.png"); - let _ = std::fs::remove_dir_all(&dir); - } - - // Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default - // 8 px apart, too close to read or aim at. - #[test] - fn in_window_menu_headings_are_spaced_apart() { - let mut a = app(); - a.native_menu = false; - let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); - h.step(); - h.step(); - let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); - let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); - for pair in titles.windows(2) { - let gap = pair[1].left() - pair[0].right(); - assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); - } - } - - /// A backup the host refuses (changes still unsaved, issue #1508) says why when started from - /// the menu, as a refused export does; the refusal used to reach only the control channel. - #[test] - fn a_refused_backup_from_the_menu_shows_why() { - // the host refuses, as the web one does while changes are still unsaved (issue #1508) - let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; - let services = crate::Services { - backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), - ..Default::default() - }; - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); - let t = std::time::Duration::from_secs(30); - h.settle(t); - for id in ["menu:File", "menuitem:file.backupLibrary"] { - let r = h.request("ui.clickWidget", json!({"id": id}), t); - assert_eq!(r["ok"], true, "{id}: {r}"); - h.step(); - } - // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) - let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); - assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); - } - - /// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search - /// failures as errors instead of returning `Ok(null)`. - #[test] - fn find_missing_waited_invalid_folder_returns_error_with_zero_mutations() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-err-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let dummy = dir.join("dummy.png"); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(&dummy, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dummy.to_string_lossy()]})).unwrap(); - // remove the file on disk so the photo is missing - std::fs::remove_file(&dummy).unwrap(); - let id = app.session.catalog.photos().next().unwrap().id; - let original_path = match &app.session.catalog.photo(id).unwrap().source { - lightcraft_catalog::Source::File { path } => path.clone(), - _ => panic!("expected file source"), - }; - let undo_count_before = app.session.undo.len(); - - // 1. Point at a nonexistent directory - let bad_dir = dir.join("nonexistent_subdir"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": bad_dir.to_string_lossy(), "wait": true})).unwrap_err(); - assert!(err.contains("not a folder"), "expected 'not a folder' error, got: {err}"); - assert!(app.ui.toast.as_ref().map(|t| t.0.contains("not a folder")).unwrap_or(false), "toast should reflect failure"); - assert!(app.ui.last_find_missing.is_none(), "last_find_missing must remain None on failure"); - - // 2. Invalid timeout parameters are rejected cleanly - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": -1.0})).unwrap_err(); - assert!(err.contains("non-negative number of seconds"), "{err}"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": "invalid"})).unwrap_err(); - assert!(err.contains("number of seconds"), "{err}"); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 1e300})).unwrap_err(); - assert!(err.contains("too large"), "{err}"); - - // Catalog and photo path must not have been mutated - assert_eq!(app.session.undo.len(), undo_count_before, "no undo steps added on failed search"); - let path_after = match &app.session.catalog.photo(id).unwrap().source { - lightcraft_catalog::Source::File { path } => path.clone(), - _ => panic!("expected file source"), - }; - assert_eq!(path_after, original_path, "missing photo path must not change on failure"); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` returns structured results on success, - /// and background mode (`wait: false` or omitted) continues returning `{"background": true}`. - #[test] - fn find_missing_waited_structured_results_and_background_mode() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-bg-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("orig")).unwrap(); - std::fs::create_dir_all(dir.join("found")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); - - // Background mode (wait omitted): returns {"background": true} immediately - let bg = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); - assert_eq!(bg, json!({"background": true})); - - // Wait for background task to complete - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); - let last = app.ui.last_find_missing.as_ref().unwrap(); - assert_eq!(last["found"].as_array().map(Vec::len), Some(1)); - - // Now move again and run with wait: true -> returns structured results directly - std::fs::create_dir_all(dir.join("found2")).unwrap(); - std::fs::rename(dir.join("found/pic.png"), dir.join("found2/pic.png")).unwrap(); - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found2").to_string_lossy(), "wait": true})).unwrap(); - assert!(r.get("found").is_some(), "must have 'found' array: {r}"); - assert!(r.get("missing").is_some(), "must have 'missing' count: {r}"); - assert!(r.get("ambiguous").is_some(), "must have 'ambiguous' array: {r}"); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1)); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` when timed out returns an actionable error - /// indicating timeout and that the search continues in the background, rather than returning `Ok(null)`. - #[test] - fn find_missing_waited_timeout_behavior() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-to-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - // Injected test-only worker delay so wait times out deterministically - app.ui.find_missing_test_delay = Some(std::time::Duration::from_millis(100)); - let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 0.005})).unwrap_err(); - - assert!(err.contains("search timed out"), "error should mention timeout: {err}"); - assert!(err.contains("search continues in the background"), "error should document continuation: {err}"); - - // The background task continues running in app.tasks - assert!(app.tasks.is_running("Find Missing Photos"), "task should continue running in background"); - - // Clean up the running task so no worker hangs - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait_for(&mut app, &ctx, "Find Missing Photos", std::time::Duration::from_secs(5))); - assert!(!app.tasks.is_running("Find Missing Photos")); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1528: `file.findMissing` with `wait: true` must return when its own search finishes, - /// even if unrelated background tasks remain active in `app.tasks`. - #[test] - fn find_missing_waited_completes_while_unrelated_task_remains_active() { - let dir = std::env::temp_dir().join(format!("lc-ui-missing-multi-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("orig")).unwrap(); - std::fs::create_dir_all(dir.join("found")).unwrap(); - let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); - let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; - std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); - - let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); - app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); - std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); - - // Spawn an unrelated background task that blocks on a channel until released - let (tx_unblock, rx_unblock) = std::sync::mpsc::channel::<()>(); - crate::tasks::spawn( - &mut app, - "Unrelated Long Background Task", - None, - move || { - let _ = rx_unblock.recv(); - }, - |_app, _ctx, _| {}, - ) - .unwrap(); - assert!(app.tasks.is_running("Unrelated Long Background Task")); - - // Run file.findMissing with wait: true: must complete without waiting for the unrelated task - let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy(), "wait": true, "timeout": 5.0})).unwrap(); - assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "Find Missing must complete successfully: {r}"); - - // The unrelated task must STILL be active - assert!(app.tasks.is_running("Unrelated Long Background Task"), "unrelated task must still be running"); - - // Clean up the blocked background task - let _ = tx_unblock.send(()); - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); - assert!(app.tasks.is_empty()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - - fn auto_tag_from_tracklog() { - let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let gpx = dir.join("walk.gpx"); - std::fs::write( - &gpx, - r#" - - - "#, - ) - .unwrap(); - let mut app = app(); - let id = app.session.visible()[0].0; - app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); - app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap(); - app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap(); - // in the Photo menu; without a file dialog (headless) it is disabled - let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1; - let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") }; - assert!(!enabled); - // a file without an offset asks for the camera's time zone; confirming runs the tagging - run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap(); - let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") }; - *value = " +02:00 ".into(); - let d = app.ui.dialog.take().unwrap(); - let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - assert_eq!(r["tagged"], 1, "{r}"); - let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap(); - assert_eq!(p.meta.gps, Some((46.001, 7.002))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn metadata_preset_menu() { - let mut app = app(); - app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); - run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); - if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { - *value = "Mine".into(); - } - let d = app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); - let bar = menu_bar(&app); - let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; - assert_eq!(params["name"], "Mine"); - let other = app.session.visible()[1]; - app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); - run_item(&mut app, id, params.clone()).unwrap(); - assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); - } - - /// Native menu accelerators never pass through `shortcuts::handle`: a command queued by the - /// native menu must obey the same blocking-window/prompt policy. Settings… and About must not - /// open behind the blocking window, and Quit stays available for its existing confirmation. - #[test] - fn native_menu_commands_respect_the_blocking_window_policy() { - use crate::panels::library_problem::LibraryProblem; - use crate::state::Dialog; - - let mut h = crate::headless::Headless::new(app(), [1000.0, 760.0], 1.0); - h.settle(std::time::Duration::from_secs(20)); - h.app.ui.view = ViewMode::PhotoGrid; - h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); - - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); - assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); - assert_eq!(native_command_policy(&h.app, "view.gridToggle"), NativeCommand::Blocked); - assert_eq!(native_command_policy(&h.app, "app.quit"), NativeCommand::Quit, "Quit keeps its own confirmation"); - assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "Settings must not open behind the blocking library window"); - assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "About must not open behind the blocking library window"); - assert_eq!(run_native_command(&mut h.app, "view.gridToggle", Value::Null), NativeCommand::Blocked); - assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a menu command did not act on the library behind the window"); - assert_eq!( - run_native_command(&mut h.app, "app.quit", Value::Null), - NativeCommand::Quit, - "Quit stays for the host's existing tasks/unsaved-changes confirmation" - ); - assert!(h.app.ui.dialog.is_none()); - - // The launch list owns input the same way. - let settings = crate::state::AppSettings { - recent_libraries: vec!["/no/such/library-one".into()], - library_path: "/no/such/library-one".into(), - ..Default::default() - }; - h.app.library_problem = Some(LibraryProblem::choose(&settings)); - assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); - assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "About must not open behind the launch list"); - // The launch list's own New Library prompt is not a background barrier: it keeps its - // priority instead of being treated as an unrelated dialog behind the list. - h.app.ui.dialog = Some(Dialog::TextPrompt { - title: "New Library".into(), - hint: "Library name".into(), - value: "New Library".into(), - command: "app.newLibrary".into(), - params: json!({}), - key: "name".into(), - }); - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run, "the launch list's own prompt is not a background barrier"); - h.app.ui.dialog = None; - - // A quit/settings notice owns input in the same way. - h.app.library_problem = None; - h.app.notices.push("The settings file could not be read and was reset.".into()); - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); - assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); - assert!(h.app.ui.dialog.is_none(), "Settings must not open behind a notice"); - - // Once nothing blocks input, the same queued command runs normally. - h.app.notices.clear(); - assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run); - assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Run); - assert!(matches!(h.app.ui.dialog, Some(Dialog::Settings { .. })), "the command runs when no barrier owns input"); - } - - #[test] - fn shortcut_text_per_platform() { - assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); - assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); - assert_eq!(shortcut_text("Delete", true), "⌫"); - } - - /// Every item of a menu, submenus included. - fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { - nodes - .iter() - .flat_map(|n| match n { - MenuNode::Submenu { children, .. } => flat(children), - MenuNode::Item { .. } => vec![n], - MenuNode::Separator => vec![], - }) - .collect() - } - - /// Given the menu bar, Then an item that turns something on or off is named for the thing, - /// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". - /// Show Original is a view (\), not a switch: it keeps Lightroom's name. - #[test] - fn toggles_are_named_for_the_thing_not_show() { - let mut a = app(); - a.ui.view = ViewMode::Detail; - let shows: Vec = menu_bar(&a) - .iter() - .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) - .filter_map(|n| match n { - MenuNode::Item { id, params, label, checked: Some(_), .. } - if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => - { - Some(format!("{id}: {label}")) - } - _ => None, - }) - .collect(); - assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); - } - - /// Given every command with a menu path, Then no two of them share a label, and none is named - /// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart - /// by name ("Detail" the view and "Detail" the Edit section were two things called the same). - #[test] - fn no_two_menu_commands_share_a_label() { - let entries = crate::menus::menu_entries(&app()); - let mut clashes = Vec::new(); - for (i, e) in entries.iter().enumerate() { - if MENUS.contains(&e.label.as_str()) { - clashes.push(format!("{} is called like the {} menu", e.id, e.label)); - } - if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { - clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); - } - } - assert!(clashes.is_empty(), "{clashes:#?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/menubar/tests.rs b/crates/ui-egui/src/menubar/tests.rs new file mode 100644 index 000000000..e5645918a --- /dev/null +++ b/crates/ui-egui/src/menubar/tests.rs @@ -0,0 +1,828 @@ +use super::*; + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) +} + +fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, .. } if i == id => Some(n), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) +} + +/// #2500: a direct-grid binding takes precedence over the borrowed cycle hint. +#[test] +fn grid_menu_shortcut_hints_preserve_direct_bindings_and_clicks() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let bar = menu_bar(&a); + let nodes = &bar.iter().find(|(title, _)| title == "View").unwrap().1; + assert!(matches!(find(nodes, "view.photoGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "Cmd+Shift+1")); + assert!(matches!(find(nodes, "view.squareGrid"), Some(MenuNode::Item { shortcut: Some(sc), .. }) if sc == "G")); + // A hint never assigns the cycle key to another command. + assert_eq!(crate::shortcuts::shortcut_of(&a.ui.settings.keymap, "view.squareGrid"), None); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + run_item(&mut a, "view.photoGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid, "clicking the active row does not cycle"); + run_item(&mut a, "view.squareGrid", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + run_item(&mut a, "view.gridToggle", Value::Null).unwrap(); + assert_eq!(a.ui.view, ViewMode::PhotoGrid); +} + +/// #2500: native hints never install a borrowed modifier/function key as a direct-grid +/// accelerator. Plain G remains in AppKit's shortcut column and is dispatched by egui. +#[test] +fn grid_menu_shortcut_hints_keep_native_toggle_dispatch() { + let mut keymap = crate::shortcuts::Keymap::new(); + for id in ["view.photoGrid", "view.squareGrid"] { + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some("G")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("G")); + assert!(!native_menu_delivers(accelerator.unwrap())); + for (key, text) in [("Cmd+Shift+G", "⌘⇧G"), ("Ctrl+G", "⌃G"), ("F3", "F3")] { + crate::shortcuts::assign(&mut keymap, "view.gridToggle", Some(key)).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, id, &Value::Null, "Photo Grid", Some(key)); + assert_eq!(label, format!("Photo Grid ({text})")); + assert_eq!(accelerator, None, "egui keeps {key}"); + } + } + crate::shortcuts::assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + let (label, accelerator) = native_item_presentation(&keymap, "view.photoGrid", &Value::Null, "Photo Grid", Some("Cmd+Shift+1")); + assert_eq!(label, "Photo Grid"); + assert_eq!(accelerator, Some("Cmd+Shift+1"), "the row's own shortcut is a real accelerator"); +} + +/// #2500: the control channel runs the real keyboard path exactly once and menu commands +/// continue to select their named grid after the cycle shortcut is remapped. +#[test] +fn grid_menu_shortcut_hints_preserve_control_keyboard_dispatch() { + let mut h = crate::headless::Headless::new(app(), [960.0, 720.0], 1.0); + h.app.ui.view = ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + let timeout = std::time::Duration::from_secs(20); + let press = |h: &mut crate::headless::Headless, params| { + let reply = h.request("ui.key", params, timeout); + assert_eq!(reply["ok"], true, "{reply}"); + }; + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + crate::shortcuts::assign(&mut h.app.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + press(&mut h, json!({"key": "G"})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + press(&mut h, json!({"key": "G", "cmd": true, "shift": true})); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid); + for _ in 0..2 { + let reply = h.request("ui.menu.invoke", json!({"command": "view.squareGrid"}), timeout); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(h.app.ui.view, ViewMode::SquareGrid); + } +} + +/// #2513 review: moving a direct-grid key to the cycle command leaves the visible model +/// unchanged, but must invalidate the native cache so its old accelerator is removed. +#[test] +fn grid_menu_shortcut_hints_invalidate_native_ownership_cache() { + let mut a = app(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.photoGrid", Some("Cmd+Shift+G")).unwrap(); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.squareGrid", Some("Cmd+Shift+2")).unwrap(); + let before = menu_bar(&a); + let direct = native_menu_structure(&before, &a.ui.settings.keymap); + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + let after = menu_bar(&a); + assert_eq!(before, after, "visible hint text does not expose the ownership change"); + assert_ne!(direct, native_menu_structure(&after, &a.ui.settings.keymap), "rebuild removes the stale direct-grid accelerator"); +} + +/// #2500: grid view rows show the separate cycle command's current shortcut. +#[test] +fn grid_menu_shortcut_hints_follow_the_cycle_key() { + let mut a = app(); + let hint = |a: &LightcraftApp, id: &str| { + let bar = menu_bar(a); + let nodes = &bar.iter().find(|(title, _)| title == "View").expect("View menu").1; + match find(nodes, id).expect("grid row") { + MenuNode::Item { shortcut, .. } => shortcut.clone(), + _ => panic!("grid row must be an item"), + } + }; + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", Some("Cmd+Shift+G")).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id).as_deref(), Some("Cmd+Shift+G"), "{id}"); + } + crate::shortcuts::assign(&mut a.ui.settings.keymap, "view.gridToggle", None).unwrap(); + for id in ["view.photoGrid", "view.squareGrid"] { + assert_eq!(hint(&a, id), None, "{id}"); + } +} + +#[test] +fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { + let mut a = app(); + let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; + let items = view(&a); + assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); + assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); + a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); + assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); +} + +/// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), +/// only while sorting at random, and the direction items are off for a shuffle. +#[test] +fn sort_menu_lists_reshuffle_once_and_only_enables_it_for_random() { + fn sort_children(bar: &[(String, Vec)]) -> Vec { + let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1; + view.iter() + .find_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Sort" => Some(children.clone()), + _ => None, + }) + .expect("Sort submenu") + } + let count = |nodes: &[MenuNode]| nodes.iter().filter(|n| matches!(n, MenuNode::Item { id, .. } if id == "library.shuffle")).count(); + let mut a = app(); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: false, .. })), "off until Random is chosen"); + a.session.execute("library.sort", &json!({"key": "random"})).expect("sort at random"); + let kids = sort_children(&menu_bar(&a)); + assert_eq!(count(&kids), 1); + assert!(matches!(find(&kids, "library.shuffle"), Some(MenuNode::Item { enabled: true, .. }))); + // no direction is shown as chosen while shuffling + let dir_checked = |kids: &[MenuNode]| { + kids.iter() + .filter_map(|n| match n { + MenuNode::Item { label, checked, .. } if label == "Ascending" || label == "Descending" => Some(*checked), + _ => None, + }) + .collect::>() + }; + assert_eq!(dir_checked(&kids), vec![None, None]); + a.session.execute("library.sort", &json!({"key": "fileName"})).expect("sort by name"); + assert!(dir_checked(&sort_children(&menu_bar(&a))).iter().all(Option::is_some), "checks come back for other keys"); +} + +/// File opens with the import entry points, worded as importing (not as adding a sidebar +/// folder): Import Photos… (⇧⌘I), Import from Folder…, Import from Device ▸. +#[test] +fn file_menu_starts_with_the_import_entry_points() { + let bar = menu_bar(&app()); + let file = &bar.iter().find(|(t, _)| t == "File").expect("File menu").1; + let labels: Vec = file + .iter() + .take(3) + .map(|n| match n { + MenuNode::Item { label, shortcut, .. } => format!("{label}{}", shortcut.as_deref().map(|s| format!(" [{s}]")).unwrap_or_default()), + MenuNode::Submenu { label, .. } => format!("{label} ▸"), + MenuNode::Separator => "---".into(), + }) + .collect(); + assert_eq!(labels[0], "Import Photos… [Cmd+Shift+I]"); + assert_eq!(labels[1..], ["Import from Folder…".to_string(), "Import from Device ▸".to_string()]); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let text = serde_json::to_string(&all).unwrap(); + assert!(!text.contains("Add Folder") && !text.contains("\"Add Photos"), "no add-folder wording left in the menus"); +} + +/// Back Up / Restore Library are the browser build's (its library lives in browser storage): +/// absent from the desktop's menus, present and wired to the host where it provides them. +#[test] +fn library_backup_items_follow_the_host() { + let all = |app: &LightcraftApp| -> Vec { menu_bar(app).into_iter().flat_map(|(_, v)| v).collect() }; + let mut desktop = app(); + assert!(find(&all(&desktop), "file.backupLibrary").is_none() && find(&all(&desktop), "file.restoreLibrary").is_none()); + assert!(run_item(&mut desktop, "file.backupLibrary", Value::Null).is_err(), "not available without the host"); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let c = calls.clone(); + let services = crate::Services { + backup_library: Some(Box::new(move |s: &mut lightcraft_engine::Session| { + c.set(c.get() + 1); + Ok(json!({"photos": s.catalog.len()})) + })), + restore_library: Some(Box::new(|_: &mut lightcraft_engine::Session| Ok(json!({"started": true})))), + ..Default::default() + }; + let mut web = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let bar = all(&web); + for id in ["file.backupLibrary", "file.restoreLibrary"] { + assert!(matches!(find(&bar, id), Some(MenuNode::Item { enabled: true, .. })), "{id}"); + } + let r = run_item(&mut web, "file.backupLibrary", Value::Null).unwrap(); + assert!(r["photos"].as_u64().unwrap() > 0); + assert_eq!(calls.get(), 1); +} + +#[test] +fn bar_follows_the_registry_and_state() { + let mut app = app(); + let bar = menu_bar(&app); + let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(titles, MENUS); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + // every engine command with a menu path is reachable (parameterized ones via submenus; items + // that follow the state, like Restore for deleted photos, when it applies) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { + assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); + } + for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { + assert!(find(&all, id).is_some(), "{id}"); + } + // no doubled or dangling separators + fn check(v: &[MenuNode]) { + assert_ne!(v.first(), Some(&MenuNode::Separator)); + assert_ne!(v.last(), Some(&MenuNode::Separator)); + for w in v.windows(2) { + assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); + } + for n in v { + if let MenuNode::Submenu { children, .. } = n { + check(children); + } + } + } + bar.iter().for_each(|(_, v)| check(v)); + // live state: undo label, enabled, checked + let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert!(!enabled); + run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); + let rated: Vec<_> = all + .iter() + .filter_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), + _ => None, + }) + .flatten() + .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) + .collect(); + assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); + let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; + assert_eq!(*checked, Some(app.ui.filmstrip)); +} + +#[test] +fn quit_ends_the_file_menu_after_unlisted_entries() { + let app = app(); + // the File menu has registry entries the layout doesn't list (appended as fallbacks) + let listed = LAYOUT.iter().find(|(t, _)| *t == "File").unwrap().1; + let unlisted: Vec = crate::menus::menu_entries(&app) + .into_iter() + .filter(|e| e.menu.first().map(String::as_str) == Some("File") && !listed.contains(&e.id.as_str()) && !HIDDEN.contains(&e.id.as_str())) + .map(|e| e.id) + .collect(); + assert!(!unlisted.is_empty(), "no fallback File entries to order"); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + // Quit is the last item, alone in its separator group + assert!(matches!(file.last(), Some(MenuNode::Item { id, .. }) if id == "app.quit"), "{file:?}"); + assert_eq!(file.get(file.len() - 2), Some(&MenuNode::Separator)); + // every fallback entry is still reachable, before Quit + for id in &unlisted { + assert!(find(&file[..file.len() - 1], id).is_some(), "{id} missing before Quit"); + } +} + +#[test] +fn export_with_preset_submenu() { + let mut app = app(); + let bar = menu_bar(&app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = + file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Export with Preset")) + else { + panic!("no Export with Preset submenu") + }; + let presets: Vec<_> = children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, .. } if id == "app.export" => params["preset"].as_str().map(str::to_string), + _ => None, + }) + .collect(); + assert_eq!(presets.first().map(String::as_str), Some("JPEG (Small)")); + assert!(find(children, "dialog.export").is_some(), "Custom… opens the dialog"); + // a user preset appears after the built-ins; choosing it exports with its settings + app.session.execute("export.savePreset", &json!({"name": "Tiny PNG", "params": {"format": "png", "width": 40}})).unwrap(); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + w.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": "/nonexistent-lc-test"})).unwrap(); + assert_eq!(r["files"][0]["width"], 40, "{r}"); + let w = written.lock().unwrap(); + assert!(w[0].0.starts_with("/nonexistent-lc-test/") && w[0].0.ends_with(".png"), "{}", w[0].0); + assert!(w[0].1.starts_with(b"\x89PNG")); + // remembered (expanded) for Export with Previous, folder included + let last = app.session.last_export.clone().unwrap(); + assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None)); + // a blank folder (the Export dialog's Folder field cleared) is refused with a clear message + // instead of writing into the working directory + let n = w.len(); + drop(w); + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); + assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); + assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); + // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it + // gets its own error rather than a file written elsewhere (issue #956) + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); + let e = r.unwrap_err(); + assert!(e.contains("no folder on disk"), "{e}"); + assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); +} + +#[test] +fn select_by_submenu_and_text_prompt() { + let mut app = app(); + let bar = menu_bar(&app); + let edit = &bar.iter().find(|(t, _)| t == "Edit").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = edit.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Select by")) + else { + panic!("no Select by submenu") + }; + let Some(MenuNode::Item { id, params, .. }) = children.first() else { panic!() }; + let r = run_item(&mut app, id, params.clone()).unwrap(); + assert!(r["selected"].as_u64().is_some_and(|n| n > 0), "{r}"); + // the one-field prompt runs its command with the typed value + app.session.execute("version.create", &json!({"name": "A"})).unwrap(); + crate::panels::dialogs::prompt(&mut app, "Rename Version", "Version name", "A", "version.rename", json!({"index": 0}), "name"); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = " Final ".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let id = app.session.active().unwrap(); + assert_eq!(app.session.catalog.photo(id).unwrap().versions[0].name, "Final"); +} + +#[test] +fn help_opens_the_docs_and_merge_last_needs_photos() { + let mut app = app(); + let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let o = opened.clone(); + app.services.open_url = Some(Box::new(move |u: &str| { + o.lock().unwrap().push(u.to_string()); + Ok(()) + })); + run_item(&mut app, "app.help", Value::Null).unwrap(); + assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); + // merging with the last settings needs a selection of two or more + let first = app.session.visible()[0].0; + app.session.execute("library.select", &json!({"ids": [first]})).unwrap(); + assert!(run_item(&mut app, "merge.hdrLast", Value::Null).is_err()); +} + +/// #260: Help ▸ Open Log Folder reveals the log file the host names. Without one (the web, +/// `--memory`) or without a file manager to show it, the item is off and the command says why. +#[test] +fn open_log_folder_reveals_the_hosts_log_file() { + let mut app = app(); + let help = |app: &LightcraftApp| menu_bar(app).into_iter().find(|(t, _)| t == "Help").map(|(_, items)| items).unwrap_or_default(); + assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: false, .. })), "listed in Help, off without a log"); + assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); + let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let s = shown.clone(); + app.services.reveal = Some(Box::new(move |p: &str| { + s.lock().unwrap().push(p.to_string()); + Ok(()) + })); + // a file manager alone is not enough: this session keeps no log + assert!(!crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(run_item(&mut app, "app.openLogFolder", Value::Null).is_err()); + assert!(shown.lock().unwrap().is_empty()); + let log = "/home/a/.config/lightcraft/logs/lightcraft.log"; + app.services.log_file = Some(log.into()); + assert!(crate::menus::ui_enabled(&app, "app.openLogFolder")); + assert!(matches!(find(&help(&app), "app.openLogFolder"), Some(MenuNode::Item { enabled: true, .. }))); + let r = run_item(&mut app, "app.openLogFolder", Value::Null).unwrap(); + assert_eq!(r["path"], log); + assert_eq!(shown.lock().unwrap().as_slice(), [log]); + // the file manager's failure is the command's + app.services.reveal = Some(Box::new(|_: &str| Err("no file manager".into()))); + assert_eq!(run_item(&mut app, "app.openLogFolder", Value::Null).unwrap_err(), "no file manager"); +} + +#[test] +fn find_missing_and_locate() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("a")).unwrap(); + std::fs::create_dir_all(dir.join("b")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a/one.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("a").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("a/one.png"), dir.join("b/one.png")).unwrap(); + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("b").to_string_lossy(), "wait": true})).unwrap(); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "{r}"); + // Locate: an explicit file + std::fs::rename(dir.join("b/one.png"), dir.join("one-renamed.png")).unwrap(); + let id = app.session.catalog.photos().next().unwrap().id; + app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + run_item(&mut app, "photo.locate", json!({"path": dir.join("one-renamed.png").to_string_lossy()})).unwrap(); + let p = app.session.catalog.photo(id).unwrap(); + assert_eq!(p.file_name, "one-renamed.png"); + let _ = std::fs::remove_dir_all(&dir); +} + +// Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default +// 8 px apart, too close to read or aim at. +#[test] +fn in_window_menu_headings_are_spaced_apart() { + let mut a = app(); + a.native_menu = false; + let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); + let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); + for pair in titles.windows(2) { + let gap = pair[1].left() - pair[0].right(); + assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); + } +} + +/// A backup the host refuses (changes still unsaved, issue #1508) says why when started from +/// the menu, as a refused export does; the refusal used to reach only the control channel. +#[test] +fn a_refused_backup_from_the_menu_shows_why() { + // the host refuses, as the web one does while changes are still unsaved (issue #1508) + let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; + let services = crate::Services { + backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); + let t = std::time::Duration::from_secs(30); + h.settle(t); + for id in ["menu:File", "menuitem:file.backupLibrary"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + } + // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); +} + +/// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search +/// failures as errors instead of returning `Ok(null)`. +#[test] +fn find_missing_waited_invalid_folder_returns_error_with_zero_mutations() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-err-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dummy = dir.join("dummy.png"); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(&dummy, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dummy.to_string_lossy()]})).unwrap(); + // remove the file on disk so the photo is missing + std::fs::remove_file(&dummy).unwrap(); + let id = app.session.catalog.photos().next().unwrap().id; + let original_path = match &app.session.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("expected file source"), + }; + let undo_count_before = app.session.undo.len(); + + // 1. Point at a nonexistent directory + let bad_dir = dir.join("nonexistent_subdir"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": bad_dir.to_string_lossy(), "wait": true})).unwrap_err(); + assert!(err.contains("not a folder"), "expected 'not a folder' error, got: {err}"); + assert!(app.ui.toast.as_ref().map(|t| t.0.contains("not a folder")).unwrap_or(false), "toast should reflect failure"); + assert!(app.ui.last_find_missing.is_none(), "last_find_missing must remain None on failure"); + + // 2. Invalid timeout parameters are rejected cleanly + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": -1.0})).unwrap_err(); + assert!(err.contains("non-negative number of seconds"), "{err}"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": "invalid"})).unwrap_err(); + assert!(err.contains("number of seconds"), "{err}"); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 1e300})).unwrap_err(); + assert!(err.contains("too large"), "{err}"); + + // Catalog and photo path must not have been mutated + assert_eq!(app.session.undo.len(), undo_count_before, "no undo steps added on failed search"); + let path_after = match &app.session.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!("expected file source"), + }; + assert_eq!(path_after, original_path, "missing photo path must not change on failure"); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` returns structured results on success, +/// and background mode (`wait: false` or omitted) continues returning `{"background": true}`. +#[test] +fn find_missing_waited_structured_results_and_background_mode() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-bg-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("orig")).unwrap(); + std::fs::create_dir_all(dir.join("found")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); + + // Background mode (wait omitted): returns {"background": true} immediately + let bg = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert_eq!(bg, json!({"background": true})); + + // Wait for background task to complete + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); + let last = app.ui.last_find_missing.as_ref().unwrap(); + assert_eq!(last["found"].as_array().map(Vec::len), Some(1)); + + // Now move again and run with wait: true -> returns structured results directly + std::fs::create_dir_all(dir.join("found2")).unwrap(); + std::fs::rename(dir.join("found/pic.png"), dir.join("found2/pic.png")).unwrap(); + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found2").to_string_lossy(), "wait": true})).unwrap(); + assert!(r.get("found").is_some(), "must have 'found' array: {r}"); + assert!(r.get("missing").is_some(), "must have 'missing' count: {r}"); + assert!(r.get("ambiguous").is_some(), "must have 'ambiguous' array: {r}"); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1)); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` when timed out returns an actionable error +/// indicating timeout and that the search continues in the background, rather than returning `Ok(null)`. +#[test] +fn find_missing_waited_timeout_behavior() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-to-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + // Injected test-only worker delay so wait times out deterministically + app.ui.find_missing_test_delay = Some(std::time::Duration::from_millis(100)); + let err = run_item(&mut app, "file.findMissing", json!({"folder": dir.to_string_lossy(), "wait": true, "timeout": 0.005})).unwrap_err(); + + assert!(err.contains("search timed out"), "error should mention timeout: {err}"); + assert!(err.contains("search continues in the background"), "error should document continuation: {err}"); + + // The background task continues running in app.tasks + assert!(app.tasks.is_running("Find Missing Photos"), "task should continue running in background"); + + // Clean up the running task so no worker hangs + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait_for(&mut app, &ctx, "Find Missing Photos", std::time::Duration::from_secs(5))); + assert!(!app.tasks.is_running("Find Missing Photos")); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// #1528: `file.findMissing` with `wait: true` must return when its own search finishes, +/// even if unrelated background tasks remain active in `app.tasks`. +#[test] +fn find_missing_waited_completes_while_unrelated_task_remains_active() { + let dir = std::env::temp_dir().join(format!("lc-ui-missing-multi-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("orig")).unwrap(); + std::fs::create_dir_all(dir.join("found")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |_, _| [10, 20, 30, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("orig/pic.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Default::default()); + app.session.execute("library.import", &json!({"paths": [dir.join("orig").to_string_lossy()]})).unwrap(); + std::fs::rename(dir.join("orig/pic.png"), dir.join("found/pic.png")).unwrap(); + + // Spawn an unrelated background task that blocks on a channel until released + let (tx_unblock, rx_unblock) = std::sync::mpsc::channel::<()>(); + crate::tasks::spawn( + &mut app, + "Unrelated Long Background Task", + None, + move || { + let _ = rx_unblock.recv(); + }, + |_app, _ctx, _| {}, + ) + .unwrap(); + assert!(app.tasks.is_running("Unrelated Long Background Task")); + + // Run file.findMissing with wait: true: must complete without waiting for the unrelated task + let r = run_item(&mut app, "file.findMissing", json!({"folder": dir.join("found").to_string_lossy(), "wait": true, "timeout": 5.0})).unwrap(); + assert_eq!(r["found"].as_array().map(Vec::len), Some(1), "Find Missing must complete successfully: {r}"); + + // The unrelated task must STILL be active + assert!(app.tasks.is_running("Unrelated Long Background Task"), "unrelated task must still be running"); + + // Clean up the blocked background task + let _ = tx_unblock.send(()); + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + assert!(crate::tasks::wait(&mut app, &ctx, std::time::Duration::from_secs(5))); + assert!(app.tasks.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] + +fn auto_tag_from_tracklog() { + let dir = std::env::temp_dir().join(format!("lc-ui-tracklog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let gpx = dir.join("walk.gpx"); + std::fs::write( + &gpx, + r#" + + + "#, + ) + .unwrap(); + let mut app = app(); + let id = app.session.visible()[0].0; + app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + app.session.execute("photo.setCaptureTime", &json!({"time": "2026-05-01T12:01:00", "each": true})).unwrap(); + app.session.execute("photo.setMeta", &json!({"gps": null})).unwrap(); + // in the Photo menu; without a file dialog (headless) it is disabled + let photo_menu = menu_bar(&app).into_iter().find(|(t, _)| t == "Photo").unwrap().1; + let Some(MenuNode::Item { enabled, .. }) = find(&photo_menu, "photo.tagFromTracklog") else { panic!("not in the Photo menu") }; + assert!(!enabled); + // a file without an offset asks for the camera's time zone; confirming runs the tagging + run_item(&mut app, "photo.tagFromTracklog", json!({"path": gpx.to_string_lossy()})).unwrap(); + let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog else { panic!("no time-zone prompt") }; + *value = " +02:00 ".into(); + let d = app.ui.dialog.take().unwrap(); + let r = crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + assert_eq!(r["tagged"], 1, "{r}"); + let p = app.session.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).unwrap(); + assert_eq!(p.meta.gps, Some((46.001, 7.002))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn metadata_preset_menu() { + let mut app = app(); + app.session.execute("photo.setMeta", &json!({"copyright": "© Me", "creator": "Me"})).unwrap(); + run_item(&mut app, "dialog.saveMetadataPreset", Value::Null).unwrap(); + if let Some(crate::state::Dialog::TextPrompt { value, .. }) = &mut app.ui.dialog { + *value = "Mine".into(); + } + let d = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &d).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { id, params, .. }) = find(&all, "metadata.applyPreset") else { panic!("no preset item") }; + assert_eq!(params["name"], "Mine"); + let other = app.session.visible()[1]; + app.session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + run_item(&mut app, id, params.clone()).unwrap(); + assert_eq!(app.session.catalog.photo(other).unwrap().meta.copyright, "© Me"); +} + +/// Native menu accelerators never pass through `shortcuts::handle`: a command queued by the +/// native menu must obey the same blocking-window/prompt policy. Settings… and About must not +/// open behind the blocking window, and Quit stays available for its existing confirmation. +#[test] +fn native_menu_commands_respect_the_blocking_window_policy() { + use crate::panels::library_problem::LibraryProblem; + use crate::state::Dialog; + + let mut h = crate::headless::Headless::new(app(), [1000.0, 760.0], 1.0); + h.settle(std::time::Duration::from_secs(20)); + h.app.ui.view = ViewMode::PhotoGrid; + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "view.gridToggle"), NativeCommand::Blocked); + assert_eq!(native_command_policy(&h.app, "app.quit"), NativeCommand::Quit, "Quit keeps its own confirmation"); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "Settings must not open behind the blocking library window"); + assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "About must not open behind the blocking library window"); + assert_eq!(run_native_command(&mut h.app, "view.gridToggle", Value::Null), NativeCommand::Blocked); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a menu command did not act on the library behind the window"); + assert_eq!( + run_native_command(&mut h.app, "app.quit", Value::Null), + NativeCommand::Quit, + "Quit stays for the host's existing tasks/unsaved-changes confirmation" + ); + assert!(h.app.ui.dialog.is_none()); + + // The launch list owns input the same way. + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + assert_eq!(native_command_policy(&h.app, "app.about"), NativeCommand::Blocked); + assert_eq!(run_native_command(&mut h.app, "app.about", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "About must not open behind the launch list"); + // The launch list's own New Library prompt is not a background barrier: it keeps its + // priority instead of being treated as an unrelated dialog behind the list. + h.app.ui.dialog = Some(Dialog::TextPrompt { + title: "New Library".into(), + hint: "Library name".into(), + value: "New Library".into(), + command: "app.newLibrary".into(), + params: json!({}), + key: "name".into(), + }); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run, "the launch list's own prompt is not a background barrier"); + h.app.ui.dialog = None; + + // A quit/settings notice owns input in the same way. + h.app.library_problem = None; + h.app.notices.push("The settings file could not be read and was reset.".into()); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Blocked); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Blocked); + assert!(h.app.ui.dialog.is_none(), "Settings must not open behind a notice"); + + // Once nothing blocks input, the same queued command runs normally. + h.app.notices.clear(); + assert_eq!(native_command_policy(&h.app, "app.settings"), NativeCommand::Run); + assert_eq!(run_native_command(&mut h.app, "app.settings", Value::Null), NativeCommand::Run); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Settings { .. })), "the command runs when no barrier owns input"); +} + +#[test] +fn shortcut_text_per_platform() { + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_text("Delete", true), "⌫"); +} + +/// Every item of a menu, submenus included. +fn flat(nodes: &[MenuNode]) -> Vec<&MenuNode> { + nodes + .iter() + .flat_map(|n| match n { + MenuNode::Submenu { children, .. } => flat(children), + MenuNode::Item { .. } => vec![n], + MenuNode::Separator => vec![], + }) + .collect() +} + +/// Given the menu bar, Then an item that turns something on or off is named for the thing, +/// with its check mark saying whether it is on: "Filmstrip ✓", never "Show Filmstrip ✓". +/// Show Original is a view (\), not a switch: it keeps Lightroom's name. +#[test] +fn toggles_are_named_for_the_thing_not_show() { + let mut a = app(); + a.ui.view = ViewMode::Detail; + let shows: Vec = menu_bar(&a) + .iter() + .flat_map(|(_, items)| flat(items).into_iter().cloned().collect::>()) + .filter_map(|n| match n { + MenuNode::Item { id, params, label, checked: Some(_), .. } + if params.is_null() && label.starts_with("Show ") && id != "view.showOriginal" => + { + Some(format!("{id}: {label}")) + } + _ => None, + }) + .collect(); + assert!(shows.is_empty(), "toggles named \"Show …\": {shows:#?}"); +} + +/// Given every command with a menu path, Then no two of them share a label, and none is named +/// like a menu: the menus, the keyboard shortcuts list and the control channel tell them apart +/// by name ("Detail" the view and "Detail" the Edit section were two things called the same). +#[test] +fn no_two_menu_commands_share_a_label() { + let entries = crate::menus::menu_entries(&app()); + let mut clashes = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if MENUS.contains(&e.label.as_str()) { + clashes.push(format!("{} is called like the {} menu", e.id, e.label)); + } + if let Some(other) = entries[..i].iter().find(|o| o.label == e.label && o.id != e.id) { + clashes.push(format!("{} and {} are both {:?}", other.id, e.id, e.label)); + } + } + assert!(clashes.is_empty(), "{clashes:#?}"); +}