refactor: extract unit tests into tests directory
This commit is contained in:
+2
-562
@@ -808,565 +808,5 @@ pub fn default_config() -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/keys.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user