#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn parses_and_normalizes_documented_chords() {
assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
assert!(KeyChord::parse("ctrl+wat").is_err());
assert!(KeyChord::parse("f99").is_err());
}
#[test]
fn defaults_validate_and_cross_mode_reuse_is_allowed() {
let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
assert!(
defaults
.action(
InputContext::Board,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
)
.is_some()
);
let mut overrides = KeyBindings::default();
overrides.board.open = Some(vec!["f4".into()]);
overrides.detail.close = Some(vec!["f4".into()]);
assert!(KeyBindings::resolve(&overrides).is_ok());
}
#[test]
fn filter_modal_opens_the_picker_and_commits_on_the_apply_key() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let ctx = InputContext::FilterModal;
for code in [KeyCode::Enter, KeyCode::Char('l'), KeyCode::Right] {
let event = KeyEvent::new(code, KeyModifiers::NONE);
let action = bindings.action(ctx, event);
assert_eq!(
action,
Some(TuiAction::OpenPicker),
"{code:?} must open the focused row's picker"
);
assert_eq!(
bindings.canonical_event(ctx, TuiAction::OpenPicker),
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE),
"{code:?} must canonicalize to the picker's F2"
);
}
let commit = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
assert_eq!(bindings.action(ctx, commit), Some(TuiAction::Submit));
assert_eq!(
bindings.canonical_event(ctx, TuiAction::Submit),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
);
assert_eq!(
bindings.action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(TuiAction::OpenPicker)
);
assert!(
bindings
.action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
.is_none(),
"the unreachable `ctrl+enter` chord must not be bound at all"
);
}
#[test]
fn board_binds_v_to_views_and_shift_v_to_view_and_detail_keeps_v() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let board_view = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE);
assert_eq!(
bindings.action(InputContext::Board, board_view),
Some(TuiAction::View),
"the board must bind `V` to view"
);
assert_eq!(
bindings.canonical_event(InputContext::Board, TuiAction::View),
board_view,
"`view` must canonicalize to the board's own chord so the handler sees it as typed"
);
assert_eq!(
bindings.action(
InputContext::Board,
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
),
Some(TuiAction::Views),
"the board must bind `v` to the saved-views modal"
);
let detail_view = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
assert_eq!(
bindings.action(InputContext::Detail, detail_view),
Some(TuiAction::View),
"Detail must bind `v` to view"
);
assert_eq!(
bindings.canonical_event(InputContext::Detail, TuiAction::View),
detail_view,
"`view` must canonicalize to `v` in Detail"
);
}
#[test]
fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let cases = [
(KeyCode::Char('o'), TuiAction::Confirm),
(KeyCode::Char('y'), TuiAction::Confirm),
(KeyCode::Char('a'), TuiAction::Ignore),
(KeyCode::Esc, TuiAction::Ignore),
(KeyCode::Char('c'), TuiAction::Reedit),
(KeyCode::Char('r'), TuiAction::Reedit),
];
for (code, action) in cases {
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(code, KeyModifiers::NONE)
),
Some(action),
"{code:?} must be {action:?} in the conflict prompt"
);
}
for (action, expected) in [
(TuiAction::Confirm, KeyCode::Char('y')),
(TuiAction::Ignore, KeyCode::Enter),
(TuiAction::Reedit, KeyCode::Char('r')),
] {
assert_eq!(
bindings.canonical_event(InputContext::EditConflict, action),
KeyEvent::new(expected, KeyModifiers::NONE),
"{action:?} must hand the handler `{expected:?}`"
);
}
assert_eq!(
bindings.action(
InputContext::EditConflict,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
),
None
);
}
#[test]
fn settings_binds_column_moves_distinct_from_the_cursor() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let shifted = |ch: char| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT);
assert_eq!(
bindings.action(InputContext::Settings, shifted('K')),
Some(TuiAction::MoveColumnUp)
);
assert_eq!(
bindings.action(InputContext::Settings, shifted('J')),
Some(TuiAction::MoveColumnDown)
);
assert_eq!(
bindings.action(
InputContext::Settings,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)
),
Some(TuiAction::MoveUp)
);
assert_eq!(
bindings.action(
InputContext::Settings,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
),
Some(TuiAction::MoveDown)
);
assert_eq!(
bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnUp),
shifted('K')
);
assert_eq!(
bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnDown),
shifted('J')
);
let mut overrides = KeyBindings::default();
overrides.settings.move_column_up = Some(vec!["ctrl+u".into()]);
overrides.settings.move_column_down = Some(vec!["ctrl+d".into()]);
let overridden = KeyBindings::resolve(&overrides).unwrap();
assert_eq!(
overridden.action(
InputContext::Settings,
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)
),
Some(TuiAction::MoveColumnUp)
);
assert_eq!(
overridden.action(
InputContext::Settings,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
),
Some(TuiAction::MoveColumnDown)
);
let mut conflict = KeyBindings::default();
conflict.settings.move_column_up = Some(vec!["a".into()]);
let error = KeyBindings::resolve(&conflict).unwrap_err().to_string();
assert!(
error.contains("settings") && error.contains("move_column_up"),
"the conflict must name the mode and the action: {error}"
);
}
#[test]
fn the_mentions_view_binds_i_on_the_board_without_a_conflict() {
let bindings = KeyBindings::resolve(&KeyBindings::default())
.expect("adding open_mentions must not conflict with an existing board chord");
let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
assert_eq!(
bindings.action(InputContext::Board, key),
Some(TuiAction::OpenMentions)
);
assert_eq!(
bindings.canonical_event(InputContext::Board, TuiAction::OpenMentions),
key
);
assert_eq!(
bindings
.chords(InputContext::Board, TuiAction::OpenMentions)
.len(),
1
);
let mut overrides = KeyBindings::default();
overrides.board.open_mentions = Some(vec!["ctrl+m".into()]);
let rebound = KeyBindings::resolve(&overrides).expect("a rebind resolves");
assert_eq!(
rebound.action(
InputContext::Board,
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL)
),
Some(TuiAction::OpenMentions)
);
}
#[test]
fn conflicts_report_mode_and_actions() {
let mut overrides = KeyBindings::default();
overrides.board.open = Some(vec!["ctrl+n".into()]);
overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
}
#[test]
fn shell_defaults_resolve_mode_switches_and_toggle() {
let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
let cases = [
(KeyCode::F(1), TuiAction::SwitchToNewIssue),
(KeyCode::F(2), TuiAction::SwitchToTriage),
(KeyCode::F(3), TuiAction::SwitchToBoard),
(KeyCode::F(4), TuiAction::SwitchToCycle),
(KeyCode::F(5), TuiAction::SwitchToMilestones),
(KeyCode::F(6), TuiAction::SwitchToRepos),
(KeyCode::F(12), TuiAction::SwitchToConfig),
];
for (code, action) in cases {
assert_eq!(
bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
Some(action),
"{code:?} must switch modes in the shell"
);
assert_eq!(
bindings.canonical_event(InputContext::Shell, action),
KeyEvent::new(code, KeyModifiers::NONE),
"{action:?} must canonicalize to its default chord"
);
}
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
),
Some(TuiAction::ToggleNavigation)
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToSchedule)
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::F(11), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToLibrary)
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)
),
Some(TuiAction::SwitchToPortfolio)
);
assert_eq!(
bindings.action(
InputContext::Shell,
KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
),
None,
"F9 must stay unbound in the shell until the Data page exists"
);
}
#[test]
fn shell_conflict_reports_both_actions_and_mode() {
let mut overrides = KeyBindings::default();
overrides.shell.switch_to_board = Some(vec!["f2".into()]);
overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
assert!(
error.contains("shell")
&& error.contains("switch_to_board")
&& error.contains("switch_to_triage"),
"got {error:?}"
);
}