use super::*; use toml_edit::DocumentMut; fn configured(source: &str) -> io::Result { let document = source.parse::().unwrap(); Bindings::from_toml(document.get("keys")) } fn press( bindings: &Bindings, context: Context, code: KeyCode, modifiers: KeyModifiers, ) -> Option { bindings.resolve(context, KeyEvent::new(code, modifiers)) } #[test] fn playback_checks_are_configurable_in_views_without_stealing_modal_space() { let defaults = Bindings::default(); let overrides = configured("[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n").unwrap(); for &context in ALL { for (action, modifiers, override_code) in [ (Action::VideoToggle, KeyModifiers::NONE, KeyCode::F(20)), (Action::VideoClear, KeyModifiers::CONTROL, KeyCode::F(21)), ] { let expected = VIEW.contains(&context).then_some(action); assert_eq!( press(&defaults, context, KeyCode::Char(' '), modifiers) .filter(|resolved| *resolved == action), expected, ); assert_eq!( press(&overrides, context, override_code, KeyModifiers::NONE), expected, ); assert_ne!( press(&overrides, context, KeyCode::Char(' '), modifiers), Some(action), ); } } assert_eq!( press( &defaults, Context::TagList, KeyCode::Char(' '), KeyModifiers::NONE ), Some(Action::TagToggle), ); assert_eq!( press( &defaults, Context::SelectorNormal, KeyCode::Char(' '), KeyModifiers::NONE ), Some(Action::SelectorToggle), ); } #[test] fn enqueue_is_configurable_and_only_active_in_video_views() { let defaults = Bindings::default(); let overrides = configured("[keys.video]\nenqueue = 'f22'\n").unwrap(); for &context in ALL { let expected = VIEW.contains(&context).then_some(Action::VideoEnqueue); for modifiers in [KeyModifiers::CONTROL] { assert_eq!( press(&defaults, context, KeyCode::Enter, modifiers), expected ); assert_ne!( press(&overrides, context, KeyCode::Enter, modifiers), Some(Action::VideoEnqueue), ); } assert_eq!( press(&defaults, context, KeyCode::Enter, KeyModifiers::SUPER), None ); assert_eq!( press(&overrides, context, KeyCode::F(22), KeyModifiers::NONE), expected, ); } assert_eq!( press( &defaults, Context::Normal, KeyCode::Enter, KeyModifiers::NONE ), Some(Action::VideoPlay), ); assert!(configured("[keys.video]\nenqueue = 'enter'\n").is_err()); } #[test] fn provider_choice_is_configurable_and_normal_only() { let defaults = Bindings::default(); let overrides = configured("[keys.app]\nprovider = 'alt+p'\n").unwrap(); for &context in ALL { let expected = matches!(context, Context::Normal | Context::CollectionNormal) .then_some(Action::ProviderChoose); assert_eq!( press(&defaults, context, KeyCode::Char('p'), KeyModifiers::NONE) .filter(|action| *action == Action::ProviderChoose), expected, ); assert_eq!( press(&overrides, context, KeyCode::Char('p'), KeyModifiers::ALT), expected, ); assert_ne!( press(&overrides, context, KeyCode::Char('p'), KeyModifiers::NONE), Some(Action::ProviderChoose), ); } } #[test] fn only_section_headers_are_bold_in_the_grouped_overview() { use ratatui::{ buffer::Buffer, layout::Rect, style::Modifier, widgets::{Paragraph, Widget, Wrap}, }; let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap(); for width in [21, 64] { let area = Rect::new(0, 0, width, 16); let mut buffer = Buffer::empty(area); Paragraph::new(bindings.help_text()) .wrap(Wrap { trim: false }) .render(area, &mut buffer); assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD)); for y in 2..area.height { for x in 0..width { assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD)); } } let binding_visible = (2..area.height).any(|y| { (0..width) .map(|x| buffer[(x, y)].symbol()) .collect::() .contains("F24") }); assert!( binding_visible, "custom shortcut must remain visible after wrapping" ); } } #[test] fn contextual_cycle_and_direct_preview_bindings() { let defaults = Bindings::default(); for (context, action) in [ (Context::Normal, Action::InfoOverview), (Context::Info, Action::InfoDescription), (Context::Description, Action::InfoOverview), (Context::CollectionNormal, Action::InfoOverview), (Context::CollectionInfo, Action::InfoDescription), (Context::CollectionDescription, Action::InfoOverview), ] { assert_eq!( press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE), Some(action) ); } let bindings = configured("[keys]\nvideo.info.overview = 'I'\nvideo.info.description = 'd'\n").unwrap(); for context in VIEW { assert_eq!( press(&bindings, *context, KeyCode::Char('I'), KeyModifiers::NONE), Some(Action::InfoOverview) ); assert_eq!( press(&bindings, *context, KeyCode::Char('d'), KeyModifiers::NONE), Some(Action::InfoDescription) ); } assert_eq!( press(&defaults, Context::Info, KeyCode::Esc, KeyModifiers::NONE), Some(Action::InfoHide) ); assert_eq!( press( &defaults, Context::Description, KeyCode::Char('-'), KeyModifiers::NONE ), Some(Action::ViewPrevious) ); assert_eq!( press( &defaults, Context::Description, KeyCode::Down, KeyModifiers::NONE ), Some(Action::DescriptionNext) ); assert_eq!( press( &defaults, Context::Description, KeyCode::Char('j'), KeyModifiers::NONE ), Some(Action::VideoNext) ); assert_eq!( press( &defaults, Context::Description, KeyCode::Char('j'), KeyModifiers::CONTROL ), Some(Action::DescriptionNext) ); assert_eq!( press( &defaults, Context::SearchInput, KeyCode::Char('d'), KeyModifiers::NONE ), None ); assert_eq!( press( &defaults, Context::SelectorNormal, KeyCode::Char('j'), KeyModifiers::NONE ), Some(Action::SelectorNext) ); assert_eq!( press( &defaults, Context::SelectorInput, KeyCode::Char('j'), KeyModifiers::NONE ), None ); for context in ALL { assert_eq!( press( &defaults, *context, KeyCode::Char('c'), KeyModifiers::CONTROL ), Some(Action::Interrupt) ); } } #[test] fn modifier_normalization_is_exact_and_allocation_free() { let bindings = configured("[keys]\nvideo.info.overview = 'shift+i'\nview.history.next = 'control++'\nview.refresh = 'option+F24'\nsearch.edit = 'command+界'\n").unwrap(); for (code, modifiers) in [ (KeyCode::Char('I'), KeyModifiers::NONE), (KeyCode::Char('I'), KeyModifiers::SHIFT), (KeyCode::Char('i'), KeyModifiers::SHIFT), ] { assert_eq!( press(&bindings, Context::Normal, code, modifiers), Some(Action::InfoOverview) ); } assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('i'), KeyModifiers::NONE ), None ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('I'), KeyModifiers::ALT ), None ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('?'), KeyModifiers::SHIFT ), Some(Action::Help) ); for (code, modifiers) in [ (KeyCode::BackTab, KeyModifiers::NONE), (KeyCode::BackTab, KeyModifiers::SHIFT), (KeyCode::Tab, KeyModifiers::SHIFT), ] { assert_eq!( press(&bindings, Context::Normal, code, modifiers), Some(Action::ViewTabPrevious) ); } assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('+'), KeyModifiers::CONTROL ), Some(Action::ViewNext) ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('+'), KeyModifiers::NONE ), None ); assert_eq!( press( &bindings, Context::Normal, KeyCode::F(24), KeyModifiers::ALT ), Some(Action::ViewRefresh) ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('界'), KeyModifiers::SUPER ), Some(Action::SearchEdit) ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::ALT ), None ); } #[test] fn aliases_replace_defaults_and_can_disable_actions() { let bindings = configured("[keys.view.collection.remove]\nselected = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); let quoted = configured("[keys]\n'view.collection.remove.selected' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); assert_eq!(quoted, bindings); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('r'), KeyModifiers::NONE ), None ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('q'), KeyModifiers::NONE ), None ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('x'), KeyModifiers::NONE ), Some(Action::ViewRefresh) ); assert_eq!( press( &bindings, Context::Normal, KeyCode::Char('y'), KeyModifiers::CONTROL ), Some(Action::ViewRefresh) ); } #[test] fn collection_removal_defaults_are_scoped_and_inherit_view_actions() { let bindings = Bindings::default(); for &context in ALL { for (key, action) in [ ('x', Action::CollectionRemoveSelected), ('X', Action::CollectionRemoveBulk), ] { assert_eq!( press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE), COLLECTION.contains(&context).then_some(action), ); } assert_eq!( press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE), None, ); } for (ordinary, collection) in [ (Context::Normal, Context::CollectionNormal), (Context::Info, Context::CollectionInfo), (Context::Description, Context::CollectionDescription), ] { for &(key, action) in &bindings.contexts[ordinary as usize] { assert_eq!( bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)), Some(action), "{ordinary:?} / {collection:?}: {key:?}", ); } assert_eq!( bindings.contexts[collection as usize].len(), bindings.contexts[ordinary as usize].len() + 2, ); } } #[test] fn collection_overrides_aliases_normalize_and_disable() { let bindings = configured( "[keys.view.collection.remove]\nselected = ['option+x', 'alt+x']\nbulk = 'shift+b'\n", ) .unwrap(); let quoted = configured( "[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n", ).unwrap(); assert_eq!(bindings, quoted); assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x"); assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B"); let help = bindings.help_text().to_string(); assert!(help.contains("Alt+x")); assert!(help.lines().any(|line| line.trim_start().starts_with("B "))); for &context in ALL { for (code, modifiers, action) in [ ( KeyCode::Char('x'), KeyModifiers::ALT, Action::CollectionRemoveSelected, ), ( KeyCode::Char('b'), KeyModifiers::SHIFT, Action::CollectionRemoveBulk, ), ( KeyCode::Char('B'), KeyModifiers::NONE, Action::CollectionRemoveBulk, ), ] { assert_eq!( press(&bindings, context, code, modifiers), COLLECTION.contains(&context).then_some(action), ); } for key in ['x', 'X'] { assert_eq!( press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE), None ); } } let disabled = configured( "[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n", ).unwrap(); for &context in ALL { for key in ['x', 'X'] { assert_eq!( press(&disabled, context, KeyCode::Char(key), KeyModifiers::NONE), VIEW.contains(&context).then_some(Action::ViewRefresh), ); } } } #[test] fn collection_contexts_validate_inherited_binding_conflicts() { for source in [ "[keys]\nview.refresh = 'x'", "[keys]\nview.watch_later.browse = 'X'", "[keys]\nview.saved.browse = 'X'", "[keys]\nview.collection.remove.selected = 'q'", "[keys]\nview.collection.remove.bulk = 'down'", ] { let error = configured(source).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert!(error.to_string().contains("Collection"), "{error}"); } } #[test] fn collection_browse_shortcuts_are_distinct_and_view_scoped() { let defaults = Bindings::default(); let overrides = configured( "[keys.view.watch_later]\ncategory = ['OpTiOn+b', 'alt+b']\n[keys]\n'view.saved.category' = []\n", ) .unwrap(); for &context in ALL { for (key, modifiers, action) in [ ('W', KeyModifiers::NONE, Action::WatchLaterBrowse), ('w', KeyModifiers::SHIFT, Action::WatchLaterBrowse), ('V', KeyModifiers::NONE, Action::SavedVideoBrowse), ('v', KeyModifiers::SHIFT, Action::SavedVideoBrowse), ('w', KeyModifiers::ALT, Action::WatchLaterBrowseCategory), ('v', KeyModifiers::ALT, Action::SavedVideoBrowseCategory), ] { assert_eq!( press(&defaults, context, KeyCode::Char(key), modifiers), VIEW.contains(&context).then_some(action), ); } assert_eq!( press(&overrides, context, KeyCode::Char('b'), KeyModifiers::ALT), VIEW.contains(&context) .then_some(Action::WatchLaterBrowseCategory), ); for key in ['w', 'v'] { assert_eq!( press(&overrides, context, KeyCode::Char(key), KeyModifiers::ALT), None, ); } for key in ['W', 'V'] { assert_eq!( press(&defaults, context, KeyCode::Char(key), KeyModifiers::ALT), None, ); } } assert_eq!(overrides.label(Action::WatchLaterBrowseCategory), "Alt+b"); } #[test] fn collection_browsing_and_save_overrides_retain_contexts() { let bindings = configured( "[keys.video.watch_later]\nsave = 'alt+w'\n[keys.video.saved]\nsave = 'alt+v'\n[keys.view.watch_later]\nbrowse = 'shift+x'\ncategory = []\nfilter = 'ctrl+f'\n[keys.view.saved]\nbrowse = 'alt+b'\ncategory = []\n[keys.view.collection.remove]\nbulk = []\n", ).unwrap(); for &context in ALL { for (key, modifiers, action, enabled) in [ ( 'w', KeyModifiers::ALT, Action::WatchLaterSave, VIEW.contains(&context), ), ( 'v', KeyModifiers::ALT, Action::SavedVideoSave, VIEW.contains(&context), ), ( 'x', KeyModifiers::SHIFT, Action::WatchLaterBrowse, VIEW.contains(&context), ), ( 'f', KeyModifiers::CONTROL, Action::WatchLaterFilter, matches!(context, Context::Normal | Context::CollectionNormal), ), ( 'b', KeyModifiers::ALT, Action::SavedVideoBrowse, VIEW.contains(&context), ), ] { assert_eq!( press(&bindings, context, KeyCode::Char(key), modifiers), enabled.then_some(action) ); } } } #[test] fn obsolete_removal_names_are_unknown() { for name in [ "view.watch_later.remove", "video.watch_later.remove", "video.saved.remove", ] { let error = configured(&format!("[keys]\n'{name}' = []")).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert!(error.to_string().contains("unknown action"), "{error}"); assert!(error.to_string().contains(name), "{error}"); } } #[test] fn invalid_entries_and_collisions_have_actionable_errors() { for (source, path) in [ ("[keys]\nunknown = 'x'", "keys.unknown"), ("[keys.unknown]", "keys.unknown"), ("keys = []", "keys"), ("[keys]\nview = 'x'", "keys.view"), ("[keys]\nview.refresh = 1", "keys.view.refresh"), ("[keys]\nview.refresh = ['x', 1]", "keys.view.refresh[1]"), ("[keys]\nview.refresh = 'hyper+x'", "keys.view.refresh"), ( "[keys]\nview.refresh = 'ctrl+control+x'", "keys.view.refresh", ), ("[keys]\nview.refresh = 'ctrl+'", "keys.view.refresh"), ("[keys]\nview.refresh = 'f25'", "keys.view.refresh"), ("[keys]\nview.refresh = ''", "keys.view.refresh"), ] { let error = configured(source).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert!(error.to_string().contains(path), "{error}"); } for source in [ "[keys]\n'video.info.overview' = 'I'\nvideo.info.overview = 'o'", "[keys]\nvideo.info.overview = 'o'\n'video.info.overview' = 'I'", ] { let error = configured(source).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); assert!( error.to_string().contains("keys.video.info.overview"), "{error}" ); } for source in [ "[keys]\nview.refresh = 'q'", "[keys]\nview.refresh = 'ctrl+c'", "[keys]\nsearch.edit = 'shift+g'", ] { let error = configured(source).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::InvalidData); } let collision = configured("[keys]\nview.refresh = 'q'") .unwrap_err() .to_string(); assert!(collision.contains("view.refresh") && collision.contains("app.quit")); // Disjoint contexts can use the same key, including an overridden input command. assert!(configured("[keys]\ninput.submit = 'q'").is_ok()); }