628 lines
19 KiB
Rust
628 lines
19 KiB
Rust
use super::*;
|
|
use toml_edit::DocumentMut;
|
|
|
|
fn configured(source: &str) -> io::Result<Bindings> {
|
|
let document = source.parse::<DocumentMut>().unwrap();
|
|
Bindings::from_toml(document.get("keys"))
|
|
}
|
|
|
|
fn press(
|
|
bindings: &Bindings,
|
|
context: Context,
|
|
code: KeyCode,
|
|
modifiers: KeyModifiers,
|
|
) -> Option<Action> {
|
|
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 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::<String>()
|
|
.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());
|
|
}
|