use gwm::tui::keymap::{Action, ChordResolution, KeyStroke, Keymap, Source, ACTIONS};
#[test]
fn actions_table_covers_every_variant() {
let table_variants: Vec<Action> = ACTIONS.iter().map(|(action, _)| *action).collect();
let unique: std::collections::HashSet<_> = table_variants.iter().collect();
assert_eq!(unique.len(), table_variants.len(), "ACTIONS has duplicate variants");
for variant in Action::all() {
assert!(
table_variants.contains(&variant),
"Action::{:?} is missing from ACTIONS",
variant
);
}
}
#[test]
fn action_slugs_are_unique_and_snake_case() {
let mut seen = std::collections::HashSet::new();
for (action, slug) in ACTIONS.iter() {
assert!(seen.insert(*slug), "duplicate slug {:?} for {:?}", slug, action);
assert!(
slug.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
"slug {:?} for {:?} is not snake_case ascii",
slug,
action
);
assert!(!slug.is_empty(), "empty slug for {:?}", action);
}
}
#[test]
fn action_from_slug_roundtrips() {
for (action, slug) in ACTIONS.iter() {
assert_eq!(
Action::from_slug(slug),
Some(*action),
"Action::from_slug({:?}) did not roundtrip to {:?}",
slug,
action
);
assert_eq!(action.slug(), *slug);
}
assert_eq!(Action::from_slug("does-not-exist"), None);
}
#[test]
fn parse_single_char() {
let chord = KeyStroke::parse_chord("j").unwrap();
assert_eq!(chord.len(), 1);
assert_eq!(chord[0].to_string(), "j");
}
#[test]
fn parse_named_key() {
let chord = KeyStroke::parse_chord("Tab").unwrap();
assert_eq!(chord.len(), 1);
assert_eq!(chord[0].to_string(), "Tab");
for name in [
"Esc",
"Enter",
"Up",
"Down",
"Left",
"Right",
"Backspace",
"BackTab",
"Home",
"End",
"PageUp",
"PageDown",
"F1",
"F12",
"Space",
] {
let parsed = KeyStroke::parse_chord(name).unwrap();
assert_eq!(parsed.len(), 1, "{name} did not parse to a single key");
assert_eq!(parsed[0].to_string(), name);
}
}
#[test]
fn parse_modifier_combinations() {
let chord = KeyStroke::parse_chord("Ctrl+c").unwrap();
assert_eq!(chord.len(), 1);
assert_eq!(chord[0].to_string(), "Ctrl+c");
let a = KeyStroke::parse_chord("Ctrl+Alt+a").unwrap();
let b = KeyStroke::parse_chord("Alt+Ctrl+a").unwrap();
assert_eq!(a, b);
}
#[test]
fn parse_chord_sequence() {
let chord = KeyStroke::parse_chord("g g").unwrap();
assert_eq!(chord.len(), 2);
assert_eq!(chord[0].to_string(), "g");
assert_eq!(chord[1].to_string(), "g");
let chord = KeyStroke::parse_chord("Ctrl+x Ctrl+s").unwrap();
assert_eq!(chord.len(), 2);
}
#[test]
fn parse_rejects_empty_string() {
assert!(KeyStroke::parse_chord("").is_err());
assert!(KeyStroke::parse_chord(" ").is_err());
}
#[test]
fn parse_rejects_unknown_named_key() {
assert!(KeyStroke::parse_chord("Foo").is_err());
assert!(KeyStroke::parse_chord("ControlEnter").is_err());
}
#[test]
fn parse_rejects_dangling_modifier() {
assert!(KeyStroke::parse_chord("Ctrl+").is_err());
assert!(KeyStroke::parse_chord("Ctrl").is_err());
}
#[test]
fn parse_rejects_duplicate_modifier() {
assert!(KeyStroke::parse_chord("Ctrl+Ctrl+c").is_err());
}
#[test]
fn parse_rejects_unknown_modifier() {
assert!(KeyStroke::parse_chord("Meta+c").is_err());
}
#[test]
fn default_keymap_resolves_core_navigation() {
let km = Keymap::defaults();
let down = KeyStroke::parse_chord("j").unwrap();
assert!(matches!(km.lookup(&down), ChordResolution::Matched(Action::Down)));
let up = KeyStroke::parse_chord("k").unwrap();
assert!(matches!(km.lookup(&up), ChordResolution::Matched(Action::Up)));
let top = KeyStroke::parse_chord("g g").unwrap();
assert!(matches!(km.lookup(&top), ChordResolution::Matched(Action::Top)));
let g_prefix = vec![top[0].clone()];
assert!(matches!(km.lookup(&g_prefix), ChordResolution::PendingPrefix));
}
#[test]
fn keymap_lookup_returns_no_match_for_unbound_key() {
let km = Keymap::defaults();
let zzz = KeyStroke::parse_chord("Ctrl+Alt+z").unwrap();
assert!(matches!(km.lookup(&zzz), ChordResolution::NoMatch));
}
#[test]
fn default_keymap_binds_sidebar_layout_and_position() {
let km = Keymap::defaults();
let toggle = KeyStroke::parse_chord("V").unwrap();
assert!(matches!(
km.lookup(&toggle),
ChordResolution::Matched(Action::ToggleSidebar)
));
let cycle = KeyStroke::parse_chord("Space").unwrap();
assert!(matches!(
km.lookup(&cycle),
ChordResolution::Matched(Action::CycleSidebarLayout)
));
let toggle_pos = KeyStroke::parse_chord("v").unwrap();
assert!(matches!(
km.lookup(&toggle_pos),
ChordResolution::Matched(Action::ToggleSidebarPosition)
));
}
#[test]
fn default_keymap_binds_sync_to_lowercase_s() {
let km = Keymap::defaults();
let sync = KeyStroke::parse_chord("s").unwrap();
assert!(matches!(km.lookup(&sync), ChordResolution::Matched(Action::Sync)));
let sidebar_mode = KeyStroke::parse_chord("S").unwrap();
assert!(
matches!(
km.lookup(&sidebar_mode),
ChordResolution::Matched(Action::ToggleSidebarMode)
),
"S must bind ToggleSidebarMode (Commits↔Stashes)"
);
}
#[test]
fn default_keymap_binds_working_tree_scroll() {
let km = Keymap::defaults();
let down = KeyStroke::parse_chord("J").unwrap();
assert!(matches!(
km.lookup(&down),
ChordResolution::Matched(Action::WtScrollDown)
));
let up = KeyStroke::parse_chord("K").unwrap();
assert!(matches!(km.lookup(&up), ChordResolution::Matched(Action::WtScrollUp)));
}
#[test]
fn default_keymap_binds_open_docs_to_dot() {
let km = Keymap::defaults();
let dot = KeyStroke::parse_chord(".").unwrap();
assert!(matches!(km.lookup(&dot), ChordResolution::Matched(Action::OpenDocs)));
}
#[test]
fn open_docs_is_rebindable_like_any_action() {
let mut km = Keymap::defaults();
km.apply_override(Action::OpenDocs, vec![KeyStroke::parse_chord("Ctrl+d").unwrap()])
.unwrap();
let rebound = KeyStroke::parse_chord("Ctrl+d").unwrap();
assert!(matches!(
km.lookup(&rebound),
ChordResolution::Matched(Action::OpenDocs)
));
let dot = KeyStroke::parse_chord(".").unwrap();
assert!(!matches!(km.lookup(&dot), ChordResolution::Matched(Action::OpenDocs)));
}
#[test]
fn default_keymap_binds_command_logs_to_3() {
let km = Keymap::defaults();
let three = KeyStroke::parse_chord("3").unwrap();
assert!(matches!(
km.lookup(&three),
ChordResolution::Matched(Action::CommandLogs)
));
}
#[test]
fn default_keymap_binds_config_panel_to_4() {
let km = Keymap::defaults();
let four = KeyStroke::parse_chord("4").unwrap();
assert!(matches!(
km.lookup(&four),
ChordResolution::Matched(Action::ConfigPanel)
));
}
#[test]
fn user_override_replaces_default_for_one_action() {
let mut km = Keymap::defaults();
km.apply_override(Action::Down, vec![KeyStroke::parse_chord("Ctrl+n").unwrap()])
.unwrap();
let ctrl_n = KeyStroke::parse_chord("Ctrl+n").unwrap();
assert!(matches!(km.lookup(&ctrl_n), ChordResolution::Matched(Action::Down)));
let j = KeyStroke::parse_chord("j").unwrap();
assert!(matches!(km.lookup(&j), ChordResolution::NoMatch));
let k = KeyStroke::parse_chord("k").unwrap();
assert!(matches!(km.lookup(&k), ChordResolution::Matched(Action::Up)));
}
#[test]
fn user_override_can_unbind_an_action() {
let mut km = Keymap::defaults();
km.apply_override(Action::Down, vec![]).unwrap();
let j = KeyStroke::parse_chord("j").unwrap();
assert!(matches!(km.lookup(&j), ChordResolution::NoMatch));
}
#[test]
fn conflicting_user_bindings_are_rejected() {
let mut km = Keymap::defaults();
km.apply_override(Action::Down, vec![KeyStroke::parse_chord("x").unwrap()])
.unwrap();
let err = km
.apply_override(Action::Up, vec![KeyStroke::parse_chord("x").unwrap()])
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("conflict"),
"expected conflict error, got: {err}"
);
}
#[test]
fn chord_that_is_strict_prefix_of_existing_binding_is_rejected() {
let mut km = Keymap::defaults();
let err = km
.apply_override(Action::TerminalFullscreen, vec![KeyStroke::parse_chord("g").unwrap()])
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("prefix"),
"expected prefix-collision error, got: {err}"
);
}
#[test]
fn primary_chord_resolves_first_default_binding() {
let km = Keymap::defaults();
assert_eq!(km.primary_chord(Action::FetchGithub).as_deref(), Some("F"));
assert_eq!(km.primary_chord(Action::Help).as_deref(), Some("?"));
assert_eq!(km.primary_chord(Action::Refresh).as_deref(), Some("f"));
}
#[test]
fn primary_chord_follows_user_override() {
let mut km = Keymap::defaults();
km.apply_override(Action::FetchGithub, vec![KeyStroke::parse_chord("Ctrl+g").unwrap()])
.unwrap();
assert_eq!(km.primary_chord(Action::FetchGithub).as_deref(), Some("Ctrl+g"));
}
#[test]
fn primary_chord_is_none_for_unbound_action() {
let mut km = Keymap::defaults();
km.apply_override(Action::FetchGithub, vec![]).unwrap();
assert_eq!(km.primary_chord(Action::FetchGithub), None);
}
#[test]
fn list_returns_entries_with_source() {
let mut km = Keymap::defaults();
km.apply_override(Action::Down, vec![KeyStroke::parse_chord("J").unwrap()])
.unwrap();
let listed = km.list();
let down_entry = listed
.iter()
.find(|entry| entry.action == Action::Down)
.expect("Down should appear in list()");
assert_eq!(down_entry.source, Source::UserConfig);
let up_entry = listed
.iter()
.find(|entry| entry.action == Action::Up)
.expect("Up should appear in list()");
assert_eq!(up_entry.source, Source::Default);
}
#[test]
fn sync_binds_to_lowercase_s_not_uppercase() {
let km = Keymap::defaults();
let s = KeyStroke::parse_chord("s").unwrap();
assert!(
matches!(km.lookup(&s), ChordResolution::Matched(Action::Sync)),
"s must resolve to Sync after #290"
);
let big_s = KeyStroke::parse_chord("S").unwrap();
assert!(
!matches!(km.lookup(&big_s), ChordResolution::Matched(Action::Sync)),
"S must not resolve to Sync after #290 (s is the new binding)"
);
}
#[test]
fn toggle_sidebar_position_binds_to_v_not_uppercase_h() {
let km = Keymap::defaults();
let v = KeyStroke::parse_chord("v").unwrap();
assert!(
matches!(km.lookup(&v), ChordResolution::Matched(Action::ToggleSidebarPosition)),
"v must resolve to ToggleSidebarPosition after #290"
);
let h = KeyStroke::parse_chord("H").unwrap();
assert!(
!matches!(km.lookup(&h), ChordResolution::Matched(Action::ToggleSidebarPosition)),
"H must not resolve to ToggleSidebarPosition after #290"
);
}
#[test]
fn toggle_delete_branch_binds_to_uppercase_d_not_p() {
let km = Keymap::defaults();
let big_d = KeyStroke::parse_chord("D").unwrap();
assert!(
matches!(km.lookup(&big_d), ChordResolution::Matched(Action::ToggleDeleteBranch)),
"D must resolve to ToggleDeleteBranch after #290"
);
let p = KeyStroke::parse_chord("p").unwrap();
assert!(
!matches!(km.lookup(&p), ChordResolution::Matched(Action::ToggleDeleteBranch)),
"p must not resolve to ToggleDeleteBranch after #290 (p is Pull)"
);
}
#[test]
fn link_prompt_binds_to_i_not_uppercase_l() {
let km = Keymap::defaults();
let i = KeyStroke::parse_chord("i").unwrap();
assert!(
matches!(km.lookup(&i), ChordResolution::Matched(Action::LinkPrompt)),
"i must resolve to LinkPrompt after #290"
);
}
#[test]
fn user_override_lazygit_fullscreen_reclaims_l_from_pty_default() {
let mut km = Keymap::defaults();
km.apply_override(Action::LazyGitFullscreen, vec![KeyStroke::parse_chord("l").unwrap()])
.unwrap();
let l = KeyStroke::parse_chord("l").unwrap();
assert!(
matches!(km.lookup(&l), ChordResolution::Matched(Action::LazyGitFullscreen)),
"l must resolve to LazyGitFullscreen after the override"
);
}
#[test]
fn user_override_terminal_fullscreen_reclaims_o_from_pty_default() {
let mut km = Keymap::defaults();
km.apply_override(Action::TerminalFullscreen, vec![KeyStroke::parse_chord("o").unwrap()])
.unwrap();
let o = KeyStroke::parse_chord("o").unwrap();
assert!(
matches!(km.lookup(&o), ChordResolution::Matched(Action::TerminalFullscreen)),
"o must resolve to TerminalFullscreen after the override"
);
}
#[test]
fn review_pty_has_default_binding_r() {
let km = Keymap::defaults();
let r = KeyStroke::parse_chord("r").unwrap();
assert!(
matches!(km.lookup(&r), ChordResolution::Matched(Action::ReviewPty)),
"r must resolve to ReviewPty by default (#290)"
);
}
#[test]
fn refresh_default_binding_does_not_include_r() {
let km = Keymap::defaults();
let binding = km
.list()
.into_iter()
.find(|b| b.action == Action::Refresh)
.expect("Refresh must be in keymap");
let r = KeyStroke::parse_chord("r").unwrap();
assert!(
!binding.chords.contains(&r),
"Refresh must not include `r` after it was reassigned to ReviewPty"
);
}
#[test]
fn user_override_review_fullscreen_reclaims_r_from_pty_default() {
let mut km = Keymap::defaults();
km.apply_override(Action::ReviewFullscreen, vec![KeyStroke::parse_chord("r").unwrap()])
.unwrap();
let r = KeyStroke::parse_chord("r").unwrap();
assert!(
matches!(km.lookup(&r), ChordResolution::Matched(Action::ReviewFullscreen)),
"r must resolve to ReviewFullscreen after the user override"
);
}
#[test]
fn from_slug_compat_accepts_pre_290_slugs() {
let cases = [
("git_tui", Action::LazyGitFullscreen),
("git_tui_overlay", Action::LazyGitPty),
("review", Action::ReviewFullscreen),
("review_overlay", Action::ReviewPty),
("yank", Action::YankPath),
("open", Action::TerminalFullscreen),
("open_terminal_overlay", Action::TerminalPty),
("open_menu", Action::BrowseLinks),
];
for (old_slug, expected) in cases {
assert_eq!(
Action::from_slug_compat(old_slug),
Some(expected),
"compat alias for {:?} must resolve to {:?}",
old_slug,
expected
);
}
}
#[test]
fn compat_alias_slugs_is_the_reverse_of_from_slug_compat() {
assert_eq!(
Action::BrowseLinks.compat_alias_slugs().collect::<Vec<_>>(),
vec!["open_menu"]
);
assert_eq!(
Action::LazyGitFullscreen.compat_alias_slugs().collect::<Vec<_>>(),
vec!["git_tui"]
);
assert!(
Action::Down.compat_alias_slugs().next().is_none(),
"an action with no rename reports no alias"
);
for action in Action::all() {
for alias in action.compat_alias_slugs() {
assert_eq!(
Action::from_slug_compat(alias),
Some(action),
"alias {alias:?} must resolve back to {action:?}"
);
}
}
}
#[test]
fn from_slug_compat_still_resolves_canonical_slugs() {
assert_eq!(
Action::from_slug_compat("lazygit_fullscreen"),
Some(Action::LazyGitFullscreen)
);
assert_eq!(Action::from_slug_compat("yank_path"), Some(Action::YankPath));
assert_eq!(Action::from_slug_compat("down"), Some(Action::Down));
assert_eq!(Action::from_slug_compat("nonexistent_slug_xyz"), None);
}
#[test]
fn exec_and_clean_overlays_are_repo_mutating() {
assert!(Action::ExecOverlay.is_repo_mutating());
assert!(Action::CleanOverlay.is_repo_mutating());
assert!(!Action::Down.is_repo_mutating());
assert!(!Action::CommandLogs.is_repo_mutating());
assert!(!Action::ConfigPanel.is_repo_mutating());
}