Tests for the grid navigation keys, Cmd+O and Shift+H

Cover Home/End and Shift+Left/Right in both grids (ends of the view, the
loupe, the user's own binding winning), Cmd+O asking for a folder once,
Shift+H toggling the HDR view, and the new keys staying out of the way
while a text field has the keyboard, while the full-screen preview covers
the grid, and while a dialog is open.
This commit is contained in:
eliets2
2026-10-13 15:16:51 +03:00
parent 4dfd78a8a8
commit 68e9d61aa6
+233
View File
@@ -237,3 +237,236 @@ fn opening_numeric_release_keeps_keys_in_the_editor() {
}
}
}
/// Press `key` with the named modifiers held (`cmd`, `shift`, `alt`, `ctrl`).
fn press(h: &mut Headless, key: &str, mods: &[&str]) {
let mut p = json!({ "key": key });
for m in mods {
p[*m] = json!(true);
}
let r = h.request("ui.key", p, T);
assert_eq!(r["ok"], true, "{r}");
h.step();
}
fn view(h: &mut Headless, p: serde_json::Value) {
let r = h.request("ui.set", p, T);
assert_eq!(r["ok"], true, "{r}");
}
/// Make `id` the only selected, active photo.
fn select(h: &mut Headless, id: lightcraft_catalog::PhotoId) {
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T);
assert_eq!(r["ok"], true, "{r}");
}
/// The demo library in Photo Grid with its middle photo selected: the photos in view, and that one.
fn grid_demo() -> (Headless, Vec<lightcraft_catalog::PhotoId>, lightcraft_catalog::PhotoId) {
let mut h = demo();
view(&mut h, json!({ "view": "photoGrid" }));
let vis = h.app.session.visible_cloned();
assert!(vis.len() > 2, "{vis:?}");
let middle = vis[vis.len() / 2];
select(&mut h, middle);
(h, vis, middle)
}
/// Count how often the folder picker is asked for; every ask is cancelled.
fn count_folder_picks(h: &mut Headless) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed = calls.clone();
h.app.services.pick_folder = Some(Box::new(move || {
observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
None
}));
calls
}
/// The declared keys of the two commands these tests bind.
#[test]
fn open_library_and_visualize_hdr_keys_are_declared() {
use crate::shortcuts::{Keymap, parse, shortcut_of};
let keymap = Keymap::new();
for (id, want) in [("view.visualizeHdr", "Shift+H"), ("app.openLibrary", "Cmd+O")] {
assert_eq!(shortcut_of(&keymap, id), Some(want), "{id}");
assert!(parse(want).is_some(), "{want} does not parse");
}
}
/// Opening a library uses the same folder picker as File > Open Library. Cancelling it
/// leaves the current photo and library intact.
#[test]
fn cmd_o_opens_the_library_picker_once() {
let mut h = demo();
let calls = count_folder_picks(&mut h);
let active = h.app.session.active();
let photos = h.app.session.catalog.len();
press(&mut h, "O", &["cmd"]);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "one picker for Cmd+O");
assert_eq!(h.app.session.active(), active);
assert_eq!(h.app.session.catalog.len(), photos);
}
/// Shift+H toggles View > Visualize HDR; Cmd+Shift+H (Histogram) is untouched.
#[test]
fn shift_h_toggles_the_hdr_view() {
let mut h = demo();
assert!(!h.app.ui.hdr_visualize);
let histogram = h.app.ui.histogram;
press(&mut h, "H", &["shift"]);
assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on");
assert_eq!(h.app.ui.histogram, histogram, "Shift+H leaves the Histogram unchanged");
press(&mut h, "H", &["shift"]);
assert!(!h.app.ui.hdr_visualize, "Shift+H turns it back off");
}
/// Home / End make the first / last photo of the view the active one, only in the library grids,
/// and yield to a key the user gave to another command.
#[test]
fn home_and_end_select_the_ends_of_the_grid() {
let (mut h, vis, _) = grid_demo();
let (first, last) = (vis[0], *vis.last().unwrap());
press(&mut h, "Home", &[]);
assert_eq!(h.app.session.active(), Some(first), "Home goes to the first photo");
assert_eq!(h.app.session.selection.ids, vec![first], "and selects only it");
press(&mut h, "End", &[]);
assert_eq!(h.app.session.active(), Some(last), "End goes to the last photo");
assert_eq!(h.app.session.selection.ids, vec![last]);
view(&mut h, json!({ "view": "squareGrid" }));
press(&mut h, "Home", &[]);
assert_eq!(h.app.session.active(), Some(first), "the Square Grid has the keys too");
select(&mut h, last);
view(&mut h, json!({ "view": "detail" }));
press(&mut h, "Home", &[]);
assert_eq!(h.app.session.active(), Some(last), "Home is a grid key");
view(&mut h, json!({ "view": "photoGrid" }));
let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Home"})).unwrap();
assert_eq!(r["shortcut"], "Home", "{r}");
press(&mut h, "Home", &[]);
assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Home binding fires");
assert_eq!(h.app.session.active(), Some(last), "the grid key stayed out of the way");
}
/// Shift+Left / Shift+Right in a grid add the previous / next photo of the view to the selection and
/// make it active, stop at either end, stay out of the loupe and yield to the user's keymap.
#[test]
fn shift_arrows_extend_the_grid_selection() {
let (mut h, vis, middle) = grid_demo();
let at = vis.iter().position(|p| *p == middle).unwrap();
let (previous, next) = (vis[at - 1], vis[at + 1]);
press(&mut h, "Right", &["shift"]);
assert_eq!(h.app.session.selection.ids, vec![middle, next], "Shift+Right adds the next photo");
assert_eq!(h.app.session.active(), Some(next), "the added photo becomes active");
// Shift+Left steps back onto a photo that is already selected: the selection stays, the active moves
press(&mut h, "Left", &["shift"]);
assert_eq!(h.app.session.selection.ids, vec![middle, next]);
assert_eq!(h.app.session.active(), Some(middle));
press(&mut h, "Left", &["shift"]);
assert_eq!(h.app.session.selection.ids, vec![middle, next, previous], "Shift+Left adds the previous photo");
assert_eq!(h.app.session.active(), Some(previous));
// the ends of the view do nothing
select(&mut h, vis[0]);
press(&mut h, "Left", &["shift"]);
assert_eq!(h.app.session.selection.ids, vec![vis[0]], "Shift+Left on the first photo is a no-op");
let last = *vis.last().unwrap();
select(&mut h, last);
press(&mut h, "Right", &["shift"]);
assert_eq!(h.app.session.selection.ids, vec![last], "Shift+Right on the last photo is a no-op");
// outside a grid it is not the extension key
select(&mut h, middle);
view(&mut h, json!({ "view": "detail" }));
press(&mut h, "Right", &["shift"]);
assert_eq!(h.app.session.selection.ids, vec![middle], "the loupe has no grid extension key");
// a key the user gave to a command wins
view(&mut h, json!({ "view": "photoGrid" }));
h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+Right"})).unwrap();
press(&mut h, "Right", &["shift"]);
assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Shift+Right binding fires");
assert_eq!(h.app.session.selection.ids, vec![middle], "the grid key stayed out of the way");
}
/// A text field with the keyboard focus keeps every one of these keys.
#[test]
fn navigation_open_library_and_hdr_keys_wait_for_a_text_field() {
let (mut h, vis, middle) = grid_demo();
let calls = count_folder_picks(&mut h);
let picks = || calls.load(std::sync::atomic::Ordering::SeqCst);
let keys: [(&str, &[&str]); 6] = [("Home", &[]), ("End", &[]), ("Right", &["shift"]), ("Left", &["shift"]), ("O", &["cmd"]), ("H", &["shift"])];
// with no text field focused, each of them acts
press(&mut h, "Home", &[]);
assert_eq!(h.app.session.active(), Some(vis[0]));
press(&mut h, "End", &[]);
assert_eq!(h.app.session.active(), vis.last().copied());
select(&mut h, middle);
press(&mut h, "Right", &["shift"]);
assert_eq!(h.app.session.selection.ids.len(), 2, "Shift+Right extends");
press(&mut h, "O", &["cmd"]);
assert_eq!(picks(), 1, "Cmd+O asks for a folder");
press(&mut h, "H", &["shift"]);
assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on");
press(&mut h, "H", &["shift"]);
assert!(!h.app.ui.hdr_visualize);
select(&mut h, middle);
// with the search box focused, none does
let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert!(h.view.ctx.egui_wants_keyboard_input(), "the search box has the keyboard");
let before = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks());
for (key, mods) in keys {
press(&mut h, key, mods);
let after = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks());
assert_eq!(after, before, "{mods:?}+{key} reached the shortcuts while the search box had the focus");
}
assert!(h.app.ui.dialog.is_none());
}
/// Home / End and Shift+Left / Shift+Right belong to the grid: while the full-screen preview covers it
/// they leave the selection alone.
#[test]
fn grid_navigation_keys_do_nothing_in_the_full_screen_preview() {
let (mut h, vis, middle) = grid_demo();
h.app.run("view.fullScreenPreview", json!({})).unwrap();
assert!(h.app.ui.fullscreen);
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the grid is the view behind the preview");
for (key, mods) in [("Home", &[][..]), ("End", &[][..]), ("Right", &["shift"][..]), ("Left", &["shift"][..])] {
press(&mut h, key, mods);
assert_eq!(h.app.session.active(), Some(middle), "{mods:?}+{key} moved the active photo in the full-screen preview");
assert_eq!(h.app.session.selection.ids, vec![middle], "{mods:?}+{key} changed the selection in the full-screen preview");
}
// back in the grid the keys work again
h.app.run("view.fullScreenPreview", json!({})).unwrap();
assert!(!h.app.ui.fullscreen);
press(&mut h, "Home", &[]);
assert_eq!(h.app.session.active(), Some(vis[0]));
}
/// A dialog owns the keyboard: Cmd+O does not ask for a folder behind it.
#[test]
fn cmd_o_does_not_fire_while_a_dialog_is_open() {
let mut h = demo();
let calls = count_folder_picks(&mut h);
h.app.run("app.shortcuts", json!({})).unwrap();
h.step();
h.step();
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
press(&mut h, "O", &["cmd"]);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0, "Cmd+O reached the shortcuts behind a dialog");
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
// with the dialog closed the same key asks for a folder
h.app.ui.dialog = None;
press(&mut h, "O", &["cmd"]);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}