gridcraft/crates/ui-egui/tests/keytips.rs

473 lines
20 KiB
Rust

//! Ribbon access keys through the complete egui input and UI path.
use egui::{Event, Key, Modifiers, PointerButton, os::OperatingSystem};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::HAlign;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
harness_on(session, OperatingSystem::Nix)
}
fn harness_on(session: Session, os: OperatingSystem) -> Harness {
let mut h = egui_kittest::Harness::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
fn events(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
h.input_mut().events.extend(events);
h.run_steps(2);
}
fn key(h: &mut Harness, key: Key) {
events(h, [key_event(key, true, Modifiers::NONE), key_event(key, false, Modifiers::NONE)]);
}
fn letter(h: &mut Harness, key: Key, text_first: bool) {
let text = Event::Text(key.name().to_ascii_lowercase());
let pressed = key_event(key, true, Modifiers::NONE);
let pair = if text_first { [text, pressed] } else { [pressed, text] };
events(h, pair.into_iter().chain([key_event(key, false, Modifiers::NONE)]));
}
fn sequence(h: &mut Harness, letters: &[Key], text_first: bool) {
key(h, Key::F10);
for &k in letters {
letter(h, k, text_first);
}
}
fn value(h: &Harness, cell: &str) -> Value {
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
fn alignment(h: &Harness) -> HAlign {
let d = h.state().session.active().unwrap();
let sh = d.wb.active().unwrap();
d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).align.h
}
fn copied_row() -> Session {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "=20+22"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "99"})).unwrap();
s.execute("edit.copy", json!({"range": "A1:B1"})).unwrap();
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
s
}
#[test]
fn alignment_sequences_consume_key_and_text_events_in_either_order() {
for text_first in [false, true] {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "keep me"})).unwrap();
let mut h = harness(s);
h.state_mut().ui.ribbon_tab = "Insert".into();
for (k, expected) in [(Key::R, HAlign::Right), (Key::L, HAlign::Left), (Key::C, HAlign::Center)] {
sequence(&mut h, &[Key::H, Key::A, k], text_first);
assert_eq!(h.state().ui.ribbon_tab, "Home");
assert_eq!(alignment(&h), expected);
assert_eq!(value(&h, "A1"), Value::from("keep me"));
assert!(h.state().editor.is_none(), "access-key letters must not begin a cell edit");
assert_eq!(h.state().keytips.prefix(), None);
}
// Consuming the terminal access key must not swallow the next ordinary keystroke.
letter(&mut h, Key::X, text_first);
key(&mut h, Key::Enter);
assert_eq!(value(&h, "A1"), Value::from("x"));
}
}
#[test]
fn paste_special_options_wait_for_enter_and_transpose_values() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::H, Key::V, Key::S], true);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
for (k, what) in [(Key::T, "formats"), (Key::V, "values"), (Key::F, "formulas"), (Key::C, "comments")] {
letter(&mut h, k, true);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("what"), Some(&json!(what)));
assert_eq!(value(&h, "D4"), Value::Empty, "choosing a paste option must not paste yet");
}
letter(&mut h, Key::V, true);
letter(&mut h, Key::E, true);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(true)));
for modifiers in [Modifiers::CTRL, Modifiers::COMMAND] {
events(
&mut h,
[
Event::ModifiersChanged(modifiers),
key_event(Key::T, true, modifiers),
key_event(Key::T, false, modifiers),
key_event(Key::E, true, modifiers),
key_event(Key::E, false, modifiers),
Event::ModifiersChanged(Modifiers::NONE),
],
);
let dialog = h.state().dialog.as_ref().unwrap();
assert_eq!(dialog.values.get("what"), Some(&json!("values")), "Ctrl/Cmd must not select a paste mnemonic");
assert_eq!(dialog.values.get("transpose"), Some(&json!(true)), "Ctrl/Cmd must not toggle transpose");
}
// Non-macOS Alt+E selects the dialog mnemonic without activating ribbon access keys.
events(
&mut h,
[
Event::ModifiersChanged(Modifiers::ALT),
Event::Text("e".into()),
key_event(Key::E, true, Modifiers::ALT),
key_event(Key::E, false, Modifiers::ALT),
Event::ModifiersChanged(Modifiers::NONE),
],
);
assert_eq!(h.state().dialog.as_ref().unwrap().values.get("transpose"), Some(&json!(false)));
assert_eq!(h.state().keytips.prefix(), None);
letter(&mut h, Key::E, false);
key(&mut h, Key::Enter);
assert!(h.state().dialog.is_none());
assert!(h.state().editor.is_none());
assert_eq!(value(&h, "D4"), Value::Number(42.0));
assert_eq!(value(&h, "D5"), Value::Number(99.0));
assert_eq!(value(&h, "E4"), Value::Empty);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.is_none());
}
#[test]
fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::H, Key::V], false);
assert_eq!(h.state().keytips.prefix(), Some("HV"));
assert!(h.state().dialog.is_none());
// This sends actual pointer events at the accessible menu item's rectangle.
// The press cancels keytips, but must not relocate or close the menu before the click.
h.get_by_label("Paste Special…").click();
h.run_steps(3);
assert_eq!(h.state().keytips.prefix(), None);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
assert_eq!(value(&h, "D4"), Value::Empty);
assert!(h.state().editor.is_none());
}
#[test]
fn legacy_paste_special_keeps_formulas_when_requested() {
let mut h = harness(copied_row());
// The legacy Edit route opens the dialog directly; F is a dialog mnemonic.
sequence(&mut h, &[Key::E, Key::S], false);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
letter(&mut h, Key::F, false);
assert_eq!(value(&h, "D4"), Value::Empty);
key(&mut h, Key::Enter);
let sh = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sh.cell(CellRef::parse("D4").unwrap()).unwrap().formula.as_ref().unwrap().text, "20+22");
assert_eq!(value(&h, "D4"), Value::Number(42.0));
assert_eq!(value(&h, "E4"), Value::Number(99.0));
assert!(h.state().editor.is_none());
key(&mut h, Key::ArrowDown);
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "D5");
}
#[test]
fn ribbon_and_legacy_autofit_sequences_resize_the_selected_column() {
for route in [&[Key::H, Key::O, Key::I][..], &[Key::O, Key::C, Key::A][..]] {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "A long heading that needs a wider column"})).unwrap();
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 12})).unwrap();
let mut h = harness(s);
let before = h.state().session.active().unwrap().wb.active().unwrap().col_width(0);
sequence(&mut h, route, false);
let after = h.state().session.active().unwrap().wb.active().unwrap().col_width(0);
assert!(after > before, "autofit must widen the narrow column");
assert_eq!(value(&h, "A1"), Value::from("A long heading that needs a wider column"));
assert!(h.state().editor.is_none());
assert_eq!(h.state().keytips.prefix(), None);
letter(&mut h, Key::X, false);
key(&mut h, Key::Enter);
assert_eq!(value(&h, "A1"), Value::from("x"));
}
}
#[test]
fn legacy_prefix_switches_to_home_once_not_every_frame() {
let mut h = harness(blank());
h.state_mut().ui.ribbon_tab = "Insert".into();
sequence(&mut h, &[Key::E], false);
assert_eq!(h.state().keytips.prefix(), Some("E"));
assert_eq!(h.state().ui.ribbon_tab, "Home");
// While the prefix stays the same, later frames must not keep forcing the Home tab.
h.state_mut().ui.ribbon_tab = "Insert".into();
h.run_steps(3);
assert_eq!(h.state().keytips.prefix(), Some("E"));
assert_eq!(h.state().ui.ribbon_tab, "Insert");
}
#[test]
fn escape_backs_out_without_clearing_copy_and_f10_toggles_off() {
let mut h = harness(copied_row());
sequence(&mut h, &[Key::H, Key::A], false);
assert_eq!(h.state().keytips.prefix(), Some("HA"));
for expected in [Some("H"), Some(""), None] {
key(&mut h, Key::Escape);
assert_eq!(h.state().keytips.prefix(), expected);
assert_eq!(h.state().session.clipboard.as_ref().unwrap().range.a1(), "A1:B1");
}
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), Some(""));
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().session.clipboard.is_some());
// A bare left Alt is the desktop entry point on non-macOS systems.
events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]);
events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE)]);
assert_eq!(h.state().keytips.prefix(), Some(""));
}
#[test]
fn mouse_press_and_window_focus_loss_cancel_keytips() {
let mut h = harness(blank());
sequence(&mut h, &[Key::H], false);
let pos = h.state().grid.cells_rect.unwrap().center();
events(
&mut h,
[
Event::PointerMoved(pos),
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
],
);
assert_eq!(h.state().keytips.prefix(), None);
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), Some(""));
h.input_mut().focused = false;
events(&mut h, [Event::WindowFocused(false)]);
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().editor.is_none());
}
#[test]
fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
let mut h = harness(blank());
// egui derives repeats from held-key state, so send presses across frames before release.
events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]);
events(&mut h, [key_event(Key::F10, true, Modifiers::NONE)]);
assert_eq!(h.state().keytips.prefix(), Some(""));
events(&mut h, [key_event(Key::F10, false, Modifiers::NONE)]);
events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]);
events(&mut h, [key_event(Key::H, true, Modifiers::NONE), Event::Text("h".into())]);
assert_eq!(h.state().keytips.prefix(), Some("H"));
events(&mut h, [key_event(Key::H, false, Modifiers::NONE)]);
letter(&mut h, Key::A, false);
events(
&mut h,
[
key_event(Key::R, true, Modifiers::NONE),
Event::Text("r".into()),
key_event(Key::R, true, Modifiers::NONE),
Event::Text("r".into()),
key_event(Key::R, false, Modifiers::NONE),
],
);
assert_eq!(alignment(&h), HAlign::Right);
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.alignRight").count(), 1);
assert!(h.state().editor.is_none());
assert_eq!(value(&h, "A1"), Value::Empty);
}
#[test]
fn tab_letters_switch_to_their_ribbon_tabs() {
let mut h = harness(blank());
for (k, tab) in [(Key::N, "Insert"), (Key::P, "Page Layout"), (Key::M, "Formulas"), (Key::A, "Data"), (Key::R, "Review"), (Key::W, "View")] {
key(&mut h, Key::F10); // arm
letter(&mut h, k, false);
assert_eq!(h.state().ui.ribbon_tab, tab, "letter {k:?}");
assert!(h.state().editor.is_none(), "tab letters must not begin a cell edit");
key(&mut h, Key::F10); // disarm
assert_eq!(h.state().keytips.prefix(), None);
}
// A terminal tab unit with no deeper sequences finishes and clears.
letter(&mut h, Key::J, false);
assert_eq!(h.state().keytips.prefix(), Some("J"), "J waits for its multi-letter unit");
letter(&mut h, Key::I, false);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly");
}
#[test]
fn view_gridlines_sequence_runs_end_to_end() {
let mut h = harness(blank());
let gridlines = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().show_gridlines;
assert!(gridlines(&h), "gridlines start visible");
key(&mut h, Key::F10);
letter(&mut h, Key::W, false);
// W has deeper sequences: it stays as an active level so View badges show.
assert_eq!(h.state().ui.ribbon_tab, "View");
assert_eq!(h.state().keytips.prefix(), Some("W"));
letter(&mut h, Key::V, false);
assert_eq!(h.state().keytips.prefix(), Some("WV"), "implicit group level waits for the last key");
letter(&mut h, Key::G, false);
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
assert!(!gridlines(&h), "WVG toggles gridlines off");
key(&mut h, Key::F10);
for k in [Key::W, Key::V, Key::G] {
letter(&mut h, k, false);
}
assert!(gridlines(&h), "WVG toggles gridlines back on");
}
#[test]
fn two_letter_unit_waits_after_first_letter_then_fires() {
let mut h = harness(blank());
key(&mut h, Key::F10);
letter(&mut h, Key::J, false);
// "J" is not a complete sequence, but "JI" starts with it: the matcher waits.
assert_eq!(h.state().keytips.prefix(), Some("J"));
assert_eq!(h.state().ui.ribbon_tab, "Home");
letter(&mut h, Key::I, false);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().editor.is_none(), "unit letters must not begin a cell edit");
}
#[test]
fn two_letter_unit_fires_when_pressed_in_one_frame() {
let mut h = harness(blank());
key(&mut h, Key::F10);
events(
&mut h,
[
key_event(Key::J, true, Modifiers::NONE),
Event::Text("j".into()),
key_event(Key::I, true, Modifiers::NONE),
Event::Text("i".into()),
key_event(Key::J, false, Modifiers::NONE),
key_event(Key::I, false, Modifiers::NONE),
],
);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
}
#[test]
fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() {
let mut h = harness(blank());
events(&mut h, [Event::Text("hello".into())]);
key(&mut h, Key::F10);
assert_eq!(h.state().keytips.prefix(), None, "F10 must not interrupt the cell editor");
assert_eq!(h.state().editor.as_ref().unwrap().text, "hello");
events(&mut h, [Event::Text("!".into())]);
key(&mut h, Key::Enter);
assert_eq!(value(&h, "A1"), Value::from("hello!"));
let mut h = harness(blank());
let altgr = Modifiers { alt: true, ctrl: true, ..Modifiers::NONE };
events(&mut h, [Event::ModifiersChanged(altgr), key_event(Key::AltRight, true, altgr), key_event(Key::Q, true, altgr), Event::Text("@".into())]);
assert_eq!(h.state().keytips.prefix(), None);
assert_eq!(h.state().editor.as_ref().unwrap().text, "@");
let mut h = harness_on(blank(), OperatingSystem::Mac);
events(&mut h, [Event::ModifiersChanged(Modifiers::ALT), key_event(Key::AltLeft, true, Modifiers::ALT)]);
events(&mut h, [Event::ModifiersChanged(Modifiers::NONE), key_event(Key::AltLeft, false, Modifiers::NONE), Event::Text("é".into())]);
assert_eq!(h.state().keytips.prefix(), None, "macOS Option must remain available for text composition");
assert_eq!(h.state().editor.as_ref().unwrap().text, "é");
}
#[test]
fn vertical_alignment_keytips_run_their_commands() {
for (letter, id) in [(Key::T, "home.alignTop"), (Key::M, "home.alignMiddle"), (Key::B, "home.alignBottom")] {
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::A, letter], false);
assert_eq!(h.state().session.journal.iter().filter(|(j, _)| j == id).count(), 1, "HA{} runs {id}", letter.name());
}
}
#[test]
fn pending_sequences_are_silent_no_ops() {
let mut h = harness(blank());
let journal = h.state().session.journal.len();
sequence(&mut h, &[Key::N, Key::E], false); // NE -> insert.equation (missing)
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
assert!(h.state().message.is_none(), "no message box for a pending sequence");
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
assert_eq!(h.state().session.journal.len(), journal, "no journal entry for a pending sequence");
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
}
#[test]
fn pending_sequences_go_live_when_the_command_lands() {
// The command lands in the catalog (here: a test-registered stand-in).
// The sequence must run it with no table edit.
gridcraft_engine::register_test_command("insert.slicer", |s, _| {
s.execute("cell.set", json!({"cell": "Z9", "input": "live"}))?;
Ok(json!({}))
});
let mut h = harness(blank());
sequence(&mut h, &[Key::N, Key::S, Key::F], false); // NSF -> insert.slicer
assert_eq!(value(&h, "Z9"), Value::from("live"), "the pending sequence ran the landed command");
assert_eq!(
h.state().session.journal.iter().filter(|(id, _)| id == "insert.slicer").count(),
1,
"the landed command is journaled like any live command"
);
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn control_keytips_open_dropdowns_and_run_handlers() {
// HFF routes to the font control: its dropdown opens (popup items queryable).
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFF must not raise an error box");
// "Calibri" appears twice: once on the combo button (the current font) and
// once inside the opened dropdown — a single node would mean it stayed closed.
assert!(h.query_all_by_label("Calibri").count() >= 2, "HFF must open the font dropdown");
// WVF toggles the formula bar: pure UI state, no engine command exists.
let mut h = harness(blank());
assert!(h.state().ui.formula_bar);
sequence(&mut h, &[Key::W, Key::V, Key::F], false);
assert!(!h.state().ui.formula_bar, "WVF must toggle the formula bar off");
// WJ runs the 100% handler with its percent param ({} would raise "missing percent").
let mut h = harness(blank());
sequence(&mut h, &[Key::W, Key::J], false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "WJ must run view.zoom with the percent param");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn every_sequence_terminates_without_panicking() {
use gridcraft_ui_egui::keytips::keytip_table::SEQUENCES;
for (seq, _entry) in SEQUENCES {
let mut h = harness(blank());
key(&mut h, Key::F10); // arm
for ch in seq.chars() {
let k = Key::from_name(&ch.to_string()).expect("sequence letters map to keys");
letter(&mut h, k, false);
}
// A finish/tab/dialog/wait state is all fine; the point is nothing panics.
}
assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran");
}