refactor: extract unit tests into tests directory

This commit is contained in:
2026-10-18 00:38:34 +02:00
parent d9c740c506
commit 3e701c99c9
19 changed files with 11353 additions and 11383 deletions
+559
View File
@@ -0,0 +1,559 @@
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());
}