refactor: extract unit tests into tests directory
This commit is contained in:
@@ -373,6 +373,8 @@ Video info shows a thumbnail, title, author/publication details, and a clipped d
|
||||
|
||||
[Miruro API notes](docs/miruro-api.md) document the native `tubular::miruro` wrapper, source-document projection, stable episode/progress contracts, and verification limits.
|
||||
|
||||
Unit tests live in `tests/unit/`, grouped by source module and loaded with `#[cfg(test)]` and `#[path]` so they retain access to private implementation details. Cargo runs them with the commands below; they are not separate integration-test crates.
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
|
||||
+2
-1203
File diff suppressed because it is too large
Load Diff
+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;
|
||||
|
||||
+2
-5846
File diff suppressed because it is too large
Load Diff
+2
-112
@@ -545,115 +545,5 @@ fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() {
|
||||
let sources = serde_json::json!({
|
||||
"episodeNumber": 1,
|
||||
"config": { "sun": { "player": "native", "proxy": false } },
|
||||
"tracks": [{ "track": "ssub", "providers": [{
|
||||
"provider": "sun",
|
||||
"servers": [{
|
||||
"headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" },
|
||||
"streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }]
|
||||
}],
|
||||
"subtitles": [{
|
||||
"file": "https://vault.keeply.top/english.jpeg", "format": "vtt",
|
||||
"language": "en", "label": "English", "default": true
|
||||
}]
|
||||
}] }]
|
||||
});
|
||||
let identity = Identity::new("catalog".into(), 1).unwrap();
|
||||
let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap();
|
||||
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
|
||||
assert_ne!(
|
||||
Identity::new("catalog".into(), 2).unwrap().key(),
|
||||
source.identity.key()
|
||||
);
|
||||
for key in [
|
||||
"miruro:catalog:01",
|
||||
"miruro:catalog:0",
|
||||
"miruro:catalog:2147483648",
|
||||
"miruro:catalog/:1",
|
||||
] {
|
||||
assert!(Identity::from_key(key).is_err());
|
||||
}
|
||||
for (pointer, value) in [
|
||||
("/episodeNumber", serde_json::json!(49)),
|
||||
("/config/sun/proxy", serde_json::json!({"segments": true})),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/streams/0/url",
|
||||
serde_json::json!("file:///secret"),
|
||||
),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/streams/0/embed",
|
||||
serde_json::json!({"url": "https://embed.example"}),
|
||||
),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/headers/origin",
|
||||
serde_json::json!("secret"),
|
||||
),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/headers/Referer",
|
||||
serde_json::json!("secret\r\nX-Injected: true"),
|
||||
),
|
||||
] {
|
||||
let mut invalid_sources = sources.clone();
|
||||
if let Some(field) = invalid_sources.pointer_mut(pointer) {
|
||||
*field = value;
|
||||
} else {
|
||||
invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] =
|
||||
value;
|
||||
}
|
||||
let error =
|
||||
SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1")
|
||||
.unwrap_err();
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
}
|
||||
let mut invalid_source = source;
|
||||
invalid_source
|
||||
.subtitles
|
||||
.push(invalid_source.subtitles[0].clone());
|
||||
assert!(invalid_source.validate().is_err());
|
||||
invalid_source.subtitles[1].default = false;
|
||||
invalid_source.subtitles[1].format = "srt".into();
|
||||
assert!(invalid_source.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
|
||||
let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#;
|
||||
let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#;
|
||||
let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#;
|
||||
let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap();
|
||||
assert_eq!(detail.media["episode_count"], 12);
|
||||
assert_eq!(detail.episodes[0]["episode_number"], 1);
|
||||
assert_eq!(detail.episodes[0]["absolute_number"], 49);
|
||||
assert_eq!(detail.episodes[0]["title"], "Locusts");
|
||||
assert!(detail.episodes[0]["canon_type"].is_null());
|
||||
for chunk in [
|
||||
"", // The promised episodes never arrive.
|
||||
r#"{"type":"chunk","id":9,"error":["failed"]}"#,
|
||||
r#"{"type":"chunk","id":9,"data":[[999]]}"#,
|
||||
r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle.
|
||||
r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_detail(&format!("{initial}\n{chunk}"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
decode_detail(r#"{"type":"redirect","location":"/"}"#)
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind(),
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/miruro.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-71
@@ -273,74 +273,5 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn catalog_and_actual_episode_numbers_remain_distinct() {
|
||||
let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null});
|
||||
let rows = catalog(vec![media.clone()]).unwrap();
|
||||
assert_eq!(rows[0].id, "opaque_ID");
|
||||
assert!(rows[0].metadata["episode_counts"]["sub"].is_null());
|
||||
assert!(catalog(vec![media.clone(), media.clone()]).is_err());
|
||||
let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500});
|
||||
let detail = || Detail {
|
||||
media: media.clone(),
|
||||
episodes: vec![episode.clone()],
|
||||
};
|
||||
let data = series("opaque_ID", detail()).unwrap();
|
||||
assert_eq!(data.episodes.len(), 1);
|
||||
assert_eq!(data.episodes[0].identity.episode, 3);
|
||||
assert_eq!(data.episodes[0].identity.media_id, "opaque_ID");
|
||||
assert_eq!(data.episodes[0].duration_seconds, Some(1500));
|
||||
assert_eq!(
|
||||
data.episodes[0].synopsis.as_deref(),
|
||||
Some("Actual synopsis")
|
||||
);
|
||||
assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none());
|
||||
assert!(series("other", detail()).is_err());
|
||||
assert!(
|
||||
series(
|
||||
"opaque_ID",
|
||||
Detail {
|
||||
media: media.clone(),
|
||||
episodes: vec![episode.clone(), episode]
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
series(
|
||||
"opaque_ID",
|
||||
Detail {
|
||||
media,
|
||||
episodes: vec![serde_json::json!({"absolute_number":49})]
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(slug("薬屋").is_err());
|
||||
assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title");
|
||||
assert_eq!(slug(" --Title-- ").unwrap(), "-title-");
|
||||
assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu");
|
||||
assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe");
|
||||
assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i");
|
||||
assert_eq!(
|
||||
slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(),
|
||||
"a-b-c"
|
||||
);
|
||||
assert_eq!(slug("A\u{0085}B").unwrap(), "ab");
|
||||
assert!(slug("!!薬屋!!").is_err());
|
||||
assert_eq!(
|
||||
title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(),
|
||||
"AB"
|
||||
);
|
||||
assert_eq!(
|
||||
title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(),
|
||||
"BC"
|
||||
);
|
||||
assert_eq!(
|
||||
title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/miruro_browser.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-687
@@ -2115,690 +2115,5 @@ pub fn run(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::crossterm::event::KeyModifiers;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::Write,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use tubular::miruro::{Detail, SourceDocument};
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"tubular-miruro-ui-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&directory).unwrap();
|
||||
Self(directory)
|
||||
}
|
||||
fn browser(&self, query: Option<&str>) -> Browser {
|
||||
let path = self.0.join("config.toml");
|
||||
fs::write(&path, "").unwrap();
|
||||
let options = Options {
|
||||
player: self.0.join("missing-player").into_os_string(),
|
||||
default_config: false,
|
||||
miruro: true,
|
||||
query: query.map(str::to_owned),
|
||||
};
|
||||
Browser::new(
|
||||
options,
|
||||
path.clone(),
|
||||
config::Config::default(),
|
||||
State::open(&path).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn media(id: &str) -> Media {
|
||||
miruro_browser::catalog(vec![serde_json::json!({"id":id, "title":{"english":format!("Series {id}")}, "episode_count":12, "description":"Series synopsis", "episode_counts":{"sub":null}})]).unwrap().remove(0)
|
||||
}
|
||||
fn series(media: &Media) -> miruro_browser::Series {
|
||||
let mut metadata = media.metadata.clone();
|
||||
metadata.as_object_mut().unwrap().remove("episode_count");
|
||||
miruro_browser::series(&media.id, Detail { media: metadata, episodes: vec![
|
||||
serde_json::json!({"episode_number":3, "absolute_number":49, "title":"Actual episode", "synopsis":"Episode synopsis", "aired_on":"2026-10-02", "duration_seconds":1500}),
|
||||
serde_json::json!({"episode_number":7, "absolute_number":53, "title":null, "duration_seconds":null}),
|
||||
] }).unwrap()
|
||||
}
|
||||
fn source(video: &Video, token: &str) -> SourceDocument {
|
||||
SourceDocument {
|
||||
version: 1,
|
||||
url: format!("https://vault.keeply.top/master.m3u8?token={token}"),
|
||||
headers: BTreeMap::new(),
|
||||
identity: Identity::from_key(&video.id).unwrap(),
|
||||
title: Some(video.title.clone()),
|
||||
subtitles: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn catalog_results(browser: &mut Browser) {
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Catalog(vec![media("first"), media("second")])),
|
||||
);
|
||||
}
|
||||
fn enter_series(browser: &mut Browser) {
|
||||
browser.open();
|
||||
let Content::Series { media, .. } = &browser.screen().content else {
|
||||
panic!("series screen");
|
||||
};
|
||||
let result = series(media);
|
||||
browser.accept(browser.generation, Ok(Response::Series(result)));
|
||||
}
|
||||
fn sources(directory: &Directory) -> Vec<Value> {
|
||||
let path = directory.0.join("playback-sessions");
|
||||
if !path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
fs::read_dir(path)
|
||||
.unwrap()
|
||||
.map(|entry| {
|
||||
serde_json::from_slice(
|
||||
&fs::read(entry.unwrap().path().join("source.json")).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
browser
|
||||
.screen_mut()
|
||||
.overview
|
||||
.push_str(&"\nMore synopsis".repeat(40));
|
||||
browser.screen_mut().overview.push_str("\nTAIL");
|
||||
browser.screen_mut().update_description();
|
||||
let selected = browser.screen().selected_video().unwrap().id;
|
||||
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Description);
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let popup = crate::preview_area(terminal.backend().buffer().area);
|
||||
let popup_text = |terminal: &Terminal<TestBackend>| -> String {
|
||||
(popup.y + 1..popup.bottom() - 1)
|
||||
.flat_map(|y| {
|
||||
(popup.x + 1..popup.right() - 1)
|
||||
.map(move |x| terminal.backend().buffer()[(x, y)].symbol())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert!(popup_text(&terminal).contains("Episode 3"));
|
||||
browser.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert_eq!(browser.screen().description_scroll, 1);
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
||||
browser.screen_mut().description_scroll = u16::MAX;
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert!(popup_text(&terminal).contains("TAIL"));
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(browser.preview.is_none());
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
||||
browser.screen_mut().description_scroll = 0;
|
||||
}
|
||||
browser.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:first:7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("apothecary"));
|
||||
assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "apothecary"));
|
||||
catalog_results(&mut browser);
|
||||
browser.select(Action::VideoNext);
|
||||
enter_series(&mut browser);
|
||||
assert_eq!(browser.screen().len(), 2); // Neither planned 12 nor absolute 49 creates rows.
|
||||
assert!(
|
||||
matches!(&browser.screen().content, Content::Series { media, .. } if media.metadata["episode_count"] == 12 && media.metadata["episode_counts"]["sub"].is_null())
|
||||
);
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:second:3"
|
||||
);
|
||||
browser.select(Action::VideoNext);
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.history(false);
|
||||
assert_eq!(browser.screen().selection.selected(), Some(1));
|
||||
assert!(
|
||||
matches!(&browser.screen().content, Content::Catalog { query, media, .. } if query == "apothecary" && media.len() == 2)
|
||||
);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.history(true);
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:second:7"
|
||||
);
|
||||
assert!(browser.pending.is_none());
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Saved, "miruro:second:7")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("old"));
|
||||
let old = browser.generation;
|
||||
browser.edit_search();
|
||||
browser.input = Some("latest".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "latest"));
|
||||
browser.accept(old, Ok(Response::Catalog(vec![media("stale")])));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
catalog_results(&mut browser);
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
let Content::Series { media, .. } = &browser.screen().content else {
|
||||
panic!("series");
|
||||
};
|
||||
let data = series(media);
|
||||
browser.history(false);
|
||||
browser.accept(stale, Ok(Response::Series(data)));
|
||||
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
|
||||
browser.history(true);
|
||||
let Content::Series { media, .. } = &browser.screen().content else {
|
||||
panic!("series");
|
||||
};
|
||||
let data = series(media);
|
||||
browser.accept(browser.generation, Ok(Response::Series(data)));
|
||||
let video = browser.screen().selected_video().unwrap();
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.history(false);
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
video.clone(),
|
||||
Box::new(source(&video, "stale")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
browser.history(true);
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.refresh();
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
video.clone(),
|
||||
Box::new(source(&video, "refreshed-away")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_watch_status_refreshes_filters_and_preserves_other_providers() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert!(browser.state.videos(CollectionKind::Watching).is_empty());
|
||||
assert!(!browser.bindings().iter().any(|(action, _)| {
|
||||
matches!(
|
||||
action,
|
||||
Action::WatchStatusWatching | Action::WatchStatusWatched
|
||||
)
|
||||
}));
|
||||
enter_series(&mut browser);
|
||||
let episode = browser.screen().selected_video().unwrap();
|
||||
let mut youtube = episode.clone();
|
||||
youtube.id = "jNQXAC9IVRw".into();
|
||||
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
|
||||
youtube.uploader_id = Some("@jawed".into());
|
||||
browser
|
||||
.state
|
||||
.save(CollectionKind::Watching, &youtube)
|
||||
.unwrap();
|
||||
browser.save(CollectionKind::WatchLater);
|
||||
browser.preview = Some(PreviewMode::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
browser.preview = None;
|
||||
browser.collection(CollectionKind::Watching);
|
||||
browser.open();
|
||||
assert!(browser.pending.is_some());
|
||||
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.collection(CollectionKind::WatchLater);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &youtube.id)
|
||||
);
|
||||
assert!(!browser.state.is_watched(&youtube.id));
|
||||
let state = State::open(&browser.config_path).unwrap();
|
||||
assert!(state.contains(CollectionKind::Watching, &episode.id));
|
||||
assert!(state.contains(CollectionKind::WatchLater, &episode.id));
|
||||
let overrides: toml_edit::DocumentMut =
|
||||
"[keys.video.status]\nwatched = 'alt+m'\n".parse().unwrap();
|
||||
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
|
||||
browser.collection(CollectionKind::Watching);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::ALT));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert!(state.contains(CollectionKind::Watching, &youtube.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let episode = browser.screen().selected_video().unwrap();
|
||||
let mut youtube = episode.clone();
|
||||
youtube.id = "jNQXAC9IVRw".into();
|
||||
youtube.title = "YouTube title".into();
|
||||
youtube.channel = Some("jawed".into());
|
||||
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
|
||||
youtube.uploader_id = Some("@jawed".into());
|
||||
for kind in [
|
||||
CollectionKind::Saved,
|
||||
CollectionKind::WatchLater,
|
||||
CollectionKind::Watching,
|
||||
] {
|
||||
browser.state.save(kind, &episode).unwrap();
|
||||
browser.state.save(kind, &youtube).unwrap();
|
||||
}
|
||||
browser.collection(CollectionKind::Saved);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.open();
|
||||
assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id));
|
||||
browser.remove(true);
|
||||
assert_eq!(
|
||||
browser.removal.as_ref().unwrap().1.as_slice(),
|
||||
std::slice::from_ref(&episode.id)
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &youtube.id));
|
||||
assert!(!browser.state.contains(CollectionKind::Saved, &episode.id));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::WatchLater, &episode.id)
|
||||
);
|
||||
let (_, report) = browser
|
||||
.state
|
||||
.miruro_session(&episode, &source(&episode, "progress"))
|
||||
.unwrap();
|
||||
let mut output = fs::OpenOptions::new();
|
||||
output.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
output.mode(0o600);
|
||||
}
|
||||
let mut output = output.open(report).unwrap();
|
||||
writeln!(output, "{}", serde_json::json!({"version":2,"event":"ended","media_identity":Identity::from_key(&episode.id).unwrap(),"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true})).unwrap();
|
||||
assert!(browser.state.poll_progress().unwrap());
|
||||
browser.rebuild_collections();
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert!(
|
||||
!browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
for kind in [CollectionKind::Watching, CollectionKind::WatchLater] {
|
||||
browser.collection(kind);
|
||||
if matches!(kind, CollectionKind::WatchLater) {
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.remove(true);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
}
|
||||
assert!(browser.state.contains(kind, &youtube.id));
|
||||
}
|
||||
let state = State::open(&browser.config_path).unwrap();
|
||||
assert!(state.contains(CollectionKind::Saved, &youtube.id));
|
||||
assert!(state.contains(CollectionKind::Watching, &youtube.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let first = browser.screen().selected_video().unwrap();
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.select(Action::VideoNext);
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
first.clone(),
|
||||
Box::new(source(&first, "not-selected")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
let selected = browser.screen().selected_video().unwrap();
|
||||
assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7);
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.collection(CollectionKind::Saved);
|
||||
browser.open();
|
||||
browser.pending = None;
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(
|
||||
selected.clone(),
|
||||
Box::new(source(&first, "wrong-identity")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
browser.open();
|
||||
browser.pending = None;
|
||||
let mut invalid = source(&selected, "private-token");
|
||||
invalid
|
||||
.headers
|
||||
.insert("X-Invalid".into(), "private-header\nvalue".into());
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(selected.clone(), Box::new(invalid))),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
assert!(!browser.status.contains("private-token"));
|
||||
assert!(!browser.status.contains("private-header"));
|
||||
for token in ["fresh-one", "fresh-two"] {
|
||||
browser.open();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title)
|
||||
);
|
||||
browser.pending = None; // Deliver the real source response without a network request.
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(
|
||||
selected.clone(),
|
||||
Box::new(source(&selected, token)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
let written = sources(&directory);
|
||||
assert_eq!(written.len(), 2);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.all(|source| source["identity"]["episode"] == 7
|
||||
&& source["identity"]["media_id"] == "first")
|
||||
);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-one"))
|
||||
);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-two"))
|
||||
);
|
||||
assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress.
|
||||
assert!(
|
||||
!browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &selected.id)
|
||||
);
|
||||
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_modal_keys_and_inputs_do_not_leak_to_main_actions() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
let overrides: toml_edit::DocumentMut =
|
||||
"[keys]\nchannel.tags = 'f2'\ntags.new = 'f3'\ntags.save = 'f4'\ninput.submit = 'f5'\n"
|
||||
.parse()
|
||||
.unwrap();
|
||||
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagList);
|
||||
browser.key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagInput);
|
||||
browser.paste("Safe");
|
||||
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
|
||||
browser.key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagList);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.state.shows().is_empty());
|
||||
assert_eq!(browser.index, 0);
|
||||
browser.key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.state.shows()[0].tags,
|
||||
BTreeSet::from(["Safe".into()])
|
||||
);
|
||||
assert!(browser.modal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_show_categories_survive_hover_and_filter_changes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(None);
|
||||
for (id, tag) in [("first", "Drama"), ("second", "Favorites")] {
|
||||
browser
|
||||
.state
|
||||
.save_show(&media(id), &[tag.into()].into())
|
||||
.unwrap();
|
||||
}
|
||||
for filter in [None, Some("no matching category")] {
|
||||
browser.category_picker();
|
||||
for code in [KeyCode::Char('j'), KeyCode::Char(' '), KeyCode::Char('k')] {
|
||||
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
if let Some(filter) = filter {
|
||||
browser.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
browser.paste(filter);
|
||||
}
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let Content::Library { shows, .. } = &browser.screen().content else {
|
||||
panic!("Checked categories must open even without a hovered match");
|
||||
};
|
||||
assert_eq!(
|
||||
shows
|
||||
.iter()
|
||||
.map(|show| show.media.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["first"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_shows_stage_cancel_reopen_and_remove_without_touching_episodes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
let key = |browser: &mut Browser, code| {
|
||||
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
};
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
key(&mut browser, KeyCode::Char('n'));
|
||||
browser.paste("Drama");
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
key(&mut browser, KeyCode::Esc);
|
||||
assert!(browser.state.shows().is_empty());
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert!(browser.state.shows()[0].tags.is_empty());
|
||||
enter_series(&mut browser);
|
||||
browser.select(Action::VideoNext);
|
||||
browser.save(CollectionKind::Saved);
|
||||
let episode = browser.screen().selected_video().unwrap().id;
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
for tag in ["Drama", "Favorites"] {
|
||||
key(&mut browser, KeyCode::Char('n'));
|
||||
browser.paste(tag);
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
}
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert_eq!(browser.state.shows()[0].media.id, "first");
|
||||
browser
|
||||
.state
|
||||
.save_show(&media("second"), &BTreeSet::from(["Drama".into()]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
library_shows(
|
||||
&browser.state,
|
||||
&BTreeSet::from(["Drama".into(), "Favorites".into()])
|
||||
)
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
browser.state = State::open(&browser.config_path).unwrap();
|
||||
key(&mut browser, KeyCode::Char('c'));
|
||||
key(&mut browser, KeyCode::Char('i'));
|
||||
browser.paste("Favorites");
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.open();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "first")
|
||||
);
|
||||
browser.history(false);
|
||||
browser.library(BTreeSet::new());
|
||||
key(&mut browser, KeyCode::Char('x'));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.history(false);
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &episode));
|
||||
assert_eq!(
|
||||
State::open(&browser.config_path).unwrap().shows()[0]
|
||||
.media
|
||||
.id,
|
||||
"second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(None);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.open();
|
||||
assert_eq!(browser.index, 0);
|
||||
browser.edit_search();
|
||||
browser.paste("Anime\nquery\t\u{1b}[31m");
|
||||
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
browser
|
||||
.input
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.all(|ch| !ch.is_control())
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let old = browser.generation;
|
||||
browser.accept(old, Err(io::Error::other("Catalog request failed")));
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
browser.search();
|
||||
browser.accept(browser.generation, Ok(Response::Catalog(Vec::new())));
|
||||
assert!(browser.screen().error.is_none());
|
||||
assert!(matches!(
|
||||
browser.screen().content,
|
||||
Content::Catalog { searched: true, .. }
|
||||
));
|
||||
browser.edit_search();
|
||||
browser.input = Some(" ".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.pending.is_none());
|
||||
assert!(matches!(
|
||||
browser.screen().content,
|
||||
Content::Catalog {
|
||||
searched: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
browser.accept(old, Ok(Response::Catalog(vec![media("stale")])));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.screen().rows.is_empty());
|
||||
fs::write(
|
||||
&browser.config_path,
|
||||
"[keys]\nsearch.edit = 'f8'\napp.quit = 'f9'\n",
|
||||
)
|
||||
.unwrap();
|
||||
browser.config = config::load(&browser.config_path).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
||||
assert!(browser.input.is_some());
|
||||
browser.input = Some("safe".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let metadata = miruro_browser::catalog(vec![serde_json::json!({
|
||||
"id":"safe", "title":{"english":"薬屋 Café\u{1b}[31m"},
|
||||
"description":"<p>First\u{1b}[31m</p><p>Second<br/>Third</p>"
|
||||
})])
|
||||
.unwrap();
|
||||
browser.accept(browser.generation, Ok(Response::Catalog(metadata)));
|
||||
assert!(
|
||||
browser
|
||||
.screen()
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.title.chars().all(|ch| !ch.is_control()))
|
||||
);
|
||||
assert!(
|
||||
browser
|
||||
.screen()
|
||||
.description
|
||||
.chars()
|
||||
.all(|ch| !ch.is_control() || ch == '\n')
|
||||
);
|
||||
assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
|
||||
assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/miruro_ui.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-336
@@ -313,339 +313,5 @@ fn left_aligned(area: Rect, size: Size) -> Rect {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use std::time::Duration;
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
thumbnail.selected = Some("fixture".into());
|
||||
let area = Rect::new(2, 2, 80, 8);
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||
let mut loading_width = 0;
|
||||
terminal
|
||||
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
let (_decode_tx, decode_rx) = mpsc::channel();
|
||||
thumbnail.decoder = Some(decode_rx);
|
||||
let mut decoding_width = 0;
|
||||
terminal
|
||||
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, decoding_width);
|
||||
thumbnail.decoder = None;
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: "fixture".into(),
|
||||
protocol: ThreadProtocol::new(
|
||||
requests_tx,
|
||||
Some(
|
||||
thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
|
||||
),
|
||||
),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
let mut preparing_width = 0;
|
||||
terminal
|
||||
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, preparing_width);
|
||||
terminal
|
||||
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, preparing_width);
|
||||
replies_tx
|
||||
.send(Ok(requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap()))
|
||||
.unwrap();
|
||||
let mut ready_width = 0;
|
||||
terminal
|
||||
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loading_width, ready_width,
|
||||
"right-hand content must not move when the image finishes loading"
|
||||
);
|
||||
thumbnail.cancel();
|
||||
thumbnail.select("fixture");
|
||||
terminal
|
||||
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, ready_width);
|
||||
replies_tx
|
||||
.send(Err("fixture renderer failure".into()))
|
||||
.unwrap();
|
||||
let mut error_width = 0;
|
||||
terminal
|
||||
.draw(|frame| error_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, error_width);
|
||||
assert!(thumbnail.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let url = "https://example.test/cover.png";
|
||||
thumbnail.selected = Some(url.into());
|
||||
thumbnail.aspect = (2, 3);
|
||||
let area = Rect::new(2, 2, 80, 8);
|
||||
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||
let mut loading_width = 0;
|
||||
terminal
|
||||
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, slot.width);
|
||||
// The artwork need not exactly match the placeholder's 2:3 aspect.
|
||||
let protocol = thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: url.into(),
|
||||
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
let response = requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap();
|
||||
replies_tx.send(Ok(response)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!thumbnail.is_loading());
|
||||
let size = thumbnail
|
||||
.image
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.protocol
|
||||
.size_for(Resize::Scale(None), slot)
|
||||
.unwrap();
|
||||
assert!(size.width <= slot.width && size.height <= slot.height);
|
||||
let font = thumbnail.picker.font_size();
|
||||
let width = u32::from(size.width) * u32::from(font.width);
|
||||
let height = u32::from(size.height) * u32::from(font.height);
|
||||
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
|
||||
thumbnail.cancel();
|
||||
thumbnail.select_url(url);
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
assert_eq!(thumbnail.selected.as_deref(), Some(url));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
thumbnail.clear_cache();
|
||||
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
|
||||
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
||||
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
||||
for format in [
|
||||
image::ImageFormat::Jpeg,
|
||||
image::ImageFormat::Png,
|
||||
image::ImageFormat::WebP,
|
||||
] {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgb8(2, 3)
|
||||
.write_to(&mut bytes, format)
|
||||
.unwrap();
|
||||
let image = decode(bytes.into_inner()).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (2, 3));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_discards_queued_decode_completion() {
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (requests_tx, _requests_rx) = mpsc::channel();
|
||||
let (_replies_tx, replies_rx) = mpsc::channel();
|
||||
let protocol = thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1));
|
||||
thumbnail.selected = Some("old-video".into());
|
||||
thumbnail.decoder = Some(rx);
|
||||
assert!(
|
||||
tx.send(Ok(ThumbnailImage {
|
||||
id: "old-video".into(),
|
||||
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
}))
|
||||
.is_ok()
|
||||
);
|
||||
thumbnail.cancel();
|
||||
thumbnail.selected = Some("new-video".into());
|
||||
thumbnail.poll();
|
||||
assert!(thumbnail.image.is_none());
|
||||
assert!(thumbnail.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() {
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let completed = |id: String| {
|
||||
let (requests, _receiver) = mpsc::channel();
|
||||
let (_sender, replies) = mpsc::channel();
|
||||
ThumbnailImage {
|
||||
id,
|
||||
protocol: ThreadProtocol::new(
|
||||
requests,
|
||||
Some(
|
||||
Picker::halfblocks()
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)),
|
||||
),
|
||||
),
|
||||
replies,
|
||||
complete: true,
|
||||
}
|
||||
};
|
||||
for index in 0..CACHE_SIZE {
|
||||
thumbnail.image = Some(completed(format!("video{index:06}")));
|
||||
thumbnail.cancel();
|
||||
}
|
||||
thumbnail.image = Some(completed("video000008".into()));
|
||||
thumbnail.selected = Some("video000008".into());
|
||||
// This is the oldest cached image: storing the outgoing image first would evict it.
|
||||
thumbnail.select("video000000");
|
||||
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000");
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
thumbnail.select("video000001");
|
||||
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001");
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
thumbnail.cancel();
|
||||
assert!(
|
||||
!thumbnail
|
||||
.cached
|
||||
.iter()
|
||||
.any(|image| image.id == "video000002")
|
||||
);
|
||||
assert!(
|
||||
thumbnail
|
||||
.cached
|
||||
.iter()
|
||||
.any(|image| image.id == "video000000")
|
||||
);
|
||||
assert!(
|
||||
thumbnail
|
||||
.cached
|
||||
.iter()
|
||||
.any(|image| image.id == "video000008")
|
||||
);
|
||||
thumbnail.clear_cache();
|
||||
assert!(thumbnail.cached.is_empty());
|
||||
assert!(thumbnail.image.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
|
||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let font = thumbnail.picker.font_size();
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
||||
48,
|
||||
36,
|
||||
image::Rgb([240, 40, 70]),
|
||||
));
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.selected = Some("thumbnail".into());
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: "thumbnail".into(),
|
||||
protocol: ThreadProtocol::new(
|
||||
requests_tx,
|
||||
Some(thumbnail.picker.new_resize_protocol(image)),
|
||||
),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
|
||||
for area in [
|
||||
Rect::new(3, 2, 80, 18),
|
||||
Rect::new(7, 4, 30, 8),
|
||||
Rect::new(3, 2, 9, 18),
|
||||
Rect::new(7, 4, 30, 8),
|
||||
] {
|
||||
let mut pending_width = 0;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
pending_width = thumbnail.draw(frame, area);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(thumbnail.is_loading());
|
||||
assert!(
|
||||
!terminal.backend().buffer().content.iter().any(|cell| {
|
||||
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||
}),
|
||||
"a resize must not render stale pixels"
|
||||
);
|
||||
let response = requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap();
|
||||
replies_tx.send(Ok(response)).unwrap();
|
||||
let mut occupied_width = 0;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
occupied_width = thumbnail.draw(frame, area);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!thumbnail.is_loading());
|
||||
assert_eq!(pending_width, occupied_width);
|
||||
let buffer = terminal.backend().buffer();
|
||||
let pixels: Vec<_> = (0..40)
|
||||
.flat_map(|y| (0..100).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| {
|
||||
let cell = &buffer[(x, y)];
|
||||
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||
})
|
||||
.collect();
|
||||
let left = pixels.iter().map(|&(x, _)| x).min().unwrap();
|
||||
let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1;
|
||||
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
|
||||
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
|
||||
assert!(left >= area.x && right <= area.right());
|
||||
assert_eq!(left, area.x);
|
||||
assert_eq!(occupied_width, right - left);
|
||||
assert!(top >= area.y && bottom <= area.bottom());
|
||||
assert!(right - left == area.width || bottom - top == area.height);
|
||||
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
|
||||
let width = u32::from(right - left) * u32::from(font.width);
|
||||
let height = u32::from(bottom - top) * u32::from(font.height);
|
||||
assert!(
|
||||
width.abs_diff(height * 4 / 3)
|
||||
<= u32::from(font.width) + u32::from(font.height) * 4 / 3
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "../tests/unit/thumbnail.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-1436
File diff suppressed because it is too large
Load Diff
+2
-1130
File diff suppressed because it is too large
Load Diff
+5836
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() {
|
||||
let sources = serde_json::json!({
|
||||
"episodeNumber": 1,
|
||||
"config": { "sun": { "player": "native", "proxy": false } },
|
||||
"tracks": [{ "track": "ssub", "providers": [{
|
||||
"provider": "sun",
|
||||
"servers": [{
|
||||
"headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" },
|
||||
"streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }]
|
||||
}],
|
||||
"subtitles": [{
|
||||
"file": "https://vault.keeply.top/english.jpeg", "format": "vtt",
|
||||
"language": "en", "label": "English", "default": true
|
||||
}]
|
||||
}] }]
|
||||
});
|
||||
let identity = Identity::new("catalog".into(), 1).unwrap();
|
||||
let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap();
|
||||
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
|
||||
assert_ne!(
|
||||
Identity::new("catalog".into(), 2).unwrap().key(),
|
||||
source.identity.key()
|
||||
);
|
||||
for key in [
|
||||
"miruro:catalog:01",
|
||||
"miruro:catalog:0",
|
||||
"miruro:catalog:2147483648",
|
||||
"miruro:catalog/:1",
|
||||
] {
|
||||
assert!(Identity::from_key(key).is_err());
|
||||
}
|
||||
for (pointer, value) in [
|
||||
("/episodeNumber", serde_json::json!(49)),
|
||||
("/config/sun/proxy", serde_json::json!({"segments": true})),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/streams/0/url",
|
||||
serde_json::json!("file:///secret"),
|
||||
),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/streams/0/embed",
|
||||
serde_json::json!({"url": "https://embed.example"}),
|
||||
),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/headers/origin",
|
||||
serde_json::json!("secret"),
|
||||
),
|
||||
(
|
||||
"/tracks/0/providers/0/servers/0/headers/Referer",
|
||||
serde_json::json!("secret\r\nX-Injected: true"),
|
||||
),
|
||||
] {
|
||||
let mut invalid_sources = sources.clone();
|
||||
if let Some(field) = invalid_sources.pointer_mut(pointer) {
|
||||
*field = value;
|
||||
} else {
|
||||
invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] = value;
|
||||
}
|
||||
let error = SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1")
|
||||
.unwrap_err();
|
||||
assert!(!error.to_string().contains("secret"));
|
||||
}
|
||||
let mut invalid_source = source;
|
||||
invalid_source
|
||||
.subtitles
|
||||
.push(invalid_source.subtitles[0].clone());
|
||||
assert!(invalid_source.validate().is_err());
|
||||
invalid_source.subtitles[1].default = false;
|
||||
invalid_source.subtitles[1].format = "srt".into();
|
||||
assert!(invalid_source.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
|
||||
let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#;
|
||||
let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#;
|
||||
let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#;
|
||||
let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap();
|
||||
assert_eq!(detail.media["episode_count"], 12);
|
||||
assert_eq!(detail.episodes[0]["episode_number"], 1);
|
||||
assert_eq!(detail.episodes[0]["absolute_number"], 49);
|
||||
assert_eq!(detail.episodes[0]["title"], "Locusts");
|
||||
assert!(detail.episodes[0]["canon_type"].is_null());
|
||||
for chunk in [
|
||||
"", // The promised episodes never arrive.
|
||||
r#"{"type":"chunk","id":9,"error":["failed"]}"#,
|
||||
r#"{"type":"chunk","id":9,"data":[[999]]}"#,
|
||||
r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle.
|
||||
r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#,
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_detail(&format!("{initial}\n{chunk}"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
decode_detail(r#"{"type":"redirect","location":"/"}"#)
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind(),
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::*;
|
||||
#[test]
|
||||
fn catalog_and_actual_episode_numbers_remain_distinct() {
|
||||
let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null});
|
||||
let rows = catalog(vec![media.clone()]).unwrap();
|
||||
assert_eq!(rows[0].id, "opaque_ID");
|
||||
assert!(rows[0].metadata["episode_counts"]["sub"].is_null());
|
||||
assert!(catalog(vec![media.clone(), media.clone()]).is_err());
|
||||
let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500});
|
||||
let detail = || Detail {
|
||||
media: media.clone(),
|
||||
episodes: vec![episode.clone()],
|
||||
};
|
||||
let data = series("opaque_ID", detail()).unwrap();
|
||||
assert_eq!(data.episodes.len(), 1);
|
||||
assert_eq!(data.episodes[0].identity.episode, 3);
|
||||
assert_eq!(data.episodes[0].identity.media_id, "opaque_ID");
|
||||
assert_eq!(data.episodes[0].duration_seconds, Some(1500));
|
||||
assert_eq!(
|
||||
data.episodes[0].synopsis.as_deref(),
|
||||
Some("Actual synopsis")
|
||||
);
|
||||
assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none());
|
||||
assert!(series("other", detail()).is_err());
|
||||
assert!(
|
||||
series(
|
||||
"opaque_ID",
|
||||
Detail {
|
||||
media: media.clone(),
|
||||
episodes: vec![episode.clone(), episode]
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
series(
|
||||
"opaque_ID",
|
||||
Detail {
|
||||
media,
|
||||
episodes: vec![serde_json::json!({"absolute_number":49})]
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(slug("薬屋").is_err());
|
||||
assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title");
|
||||
assert_eq!(slug(" --Title-- ").unwrap(), "-title-");
|
||||
assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu");
|
||||
assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe");
|
||||
assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i");
|
||||
assert_eq!(
|
||||
slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(),
|
||||
"a-b-c"
|
||||
);
|
||||
assert_eq!(slug("A\u{0085}B").unwrap(), "ab");
|
||||
assert!(slug("!!薬屋!!").is_err());
|
||||
assert_eq!(
|
||||
title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(),
|
||||
"AB"
|
||||
);
|
||||
assert_eq!(
|
||||
title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(),
|
||||
"BC"
|
||||
);
|
||||
assert_eq!(
|
||||
title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
use super::*;
|
||||
use ratatui::crossterm::event::KeyModifiers;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::Write,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use tubular::miruro::{Detail, SourceDocument};
|
||||
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"tubular-miruro-ui-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&directory).unwrap();
|
||||
Self(directory)
|
||||
}
|
||||
fn browser(&self, query: Option<&str>) -> Browser {
|
||||
let path = self.0.join("config.toml");
|
||||
fs::write(&path, "").unwrap();
|
||||
let options = Options {
|
||||
player: self.0.join("missing-player").into_os_string(),
|
||||
default_config: false,
|
||||
miruro: true,
|
||||
query: query.map(str::to_owned),
|
||||
};
|
||||
Browser::new(
|
||||
options,
|
||||
path.clone(),
|
||||
config::Config::default(),
|
||||
State::open(&path).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn media(id: &str) -> Media {
|
||||
miruro_browser::catalog(vec![serde_json::json!({"id":id, "title":{"english":format!("Series {id}")}, "episode_count":12, "description":"Series synopsis", "episode_counts":{"sub":null}})]).unwrap().remove(0)
|
||||
}
|
||||
fn series(media: &Media) -> miruro_browser::Series {
|
||||
let mut metadata = media.metadata.clone();
|
||||
metadata.as_object_mut().unwrap().remove("episode_count");
|
||||
miruro_browser::series(&media.id, Detail { media: metadata, episodes: vec![
|
||||
serde_json::json!({"episode_number":3, "absolute_number":49, "title":"Actual episode", "synopsis":"Episode synopsis", "aired_on":"2026-10-02", "duration_seconds":1500}),
|
||||
serde_json::json!({"episode_number":7, "absolute_number":53, "title":null, "duration_seconds":null}),
|
||||
] }).unwrap()
|
||||
}
|
||||
fn source(video: &Video, token: &str) -> SourceDocument {
|
||||
SourceDocument {
|
||||
version: 1,
|
||||
url: format!("https://vault.keeply.top/master.m3u8?token={token}"),
|
||||
headers: BTreeMap::new(),
|
||||
identity: Identity::from_key(&video.id).unwrap(),
|
||||
title: Some(video.title.clone()),
|
||||
subtitles: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn catalog_results(browser: &mut Browser) {
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Catalog(vec![media("first"), media("second")])),
|
||||
);
|
||||
}
|
||||
fn enter_series(browser: &mut Browser) {
|
||||
browser.open();
|
||||
let Content::Series { media, .. } = &browser.screen().content else {
|
||||
panic!("series screen");
|
||||
};
|
||||
let result = series(media);
|
||||
browser.accept(browser.generation, Ok(Response::Series(result)));
|
||||
}
|
||||
fn sources(directory: &Directory) -> Vec<Value> {
|
||||
let path = directory.0.join("playback-sessions");
|
||||
if !path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
fs::read_dir(path)
|
||||
.unwrap()
|
||||
.map(|entry| {
|
||||
serde_json::from_slice(&fs::read(entry.unwrap().path().join("source.json")).unwrap())
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
browser
|
||||
.screen_mut()
|
||||
.overview
|
||||
.push_str(&"\nMore synopsis".repeat(40));
|
||||
browser.screen_mut().overview.push_str("\nTAIL");
|
||||
browser.screen_mut().update_description();
|
||||
let selected = browser.screen().selected_video().unwrap().id;
|
||||
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Description);
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let popup = crate::preview_area(terminal.backend().buffer().area);
|
||||
let popup_text = |terminal: &Terminal<TestBackend>| -> String {
|
||||
(popup.y + 1..popup.bottom() - 1)
|
||||
.flat_map(|y| {
|
||||
(popup.x + 1..popup.right() - 1)
|
||||
.map(move |x| terminal.backend().buffer()[(x, y)].symbol())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert!(popup_text(&terminal).contains("Episode 3"));
|
||||
browser.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert_eq!(browser.screen().description_scroll, 1);
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
||||
browser.screen_mut().description_scroll = u16::MAX;
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert!(popup_text(&terminal).contains("TAIL"));
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(browser.preview.is_none());
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
||||
browser.screen_mut().description_scroll = 0;
|
||||
}
|
||||
browser.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:first:7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("apothecary"));
|
||||
assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "apothecary"));
|
||||
catalog_results(&mut browser);
|
||||
browser.select(Action::VideoNext);
|
||||
enter_series(&mut browser);
|
||||
assert_eq!(browser.screen().len(), 2); // Neither planned 12 nor absolute 49 creates rows.
|
||||
assert!(
|
||||
matches!(&browser.screen().content, Content::Series { media, .. } if media.metadata["episode_count"] == 12 && media.metadata["episode_counts"]["sub"].is_null())
|
||||
);
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:second:3"
|
||||
);
|
||||
browser.select(Action::VideoNext);
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.history(false);
|
||||
assert_eq!(browser.screen().selection.selected(), Some(1));
|
||||
assert!(
|
||||
matches!(&browser.screen().content, Content::Catalog { query, media, .. } if query == "apothecary" && media.len() == 2)
|
||||
);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.history(true);
|
||||
assert_eq!(
|
||||
browser.screen().selected_video().unwrap().id,
|
||||
"miruro:second:7"
|
||||
);
|
||||
assert!(browser.pending.is_none());
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Saved, "miruro:second:7")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("old"));
|
||||
let old = browser.generation;
|
||||
browser.edit_search();
|
||||
browser.input = Some("latest".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "latest"));
|
||||
browser.accept(old, Ok(Response::Catalog(vec![media("stale")])));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
catalog_results(&mut browser);
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
let Content::Series { media, .. } = &browser.screen().content else {
|
||||
panic!("series");
|
||||
};
|
||||
let data = series(media);
|
||||
browser.history(false);
|
||||
browser.accept(stale, Ok(Response::Series(data)));
|
||||
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
|
||||
browser.history(true);
|
||||
let Content::Series { media, .. } = &browser.screen().content else {
|
||||
panic!("series");
|
||||
};
|
||||
let data = series(media);
|
||||
browser.accept(browser.generation, Ok(Response::Series(data)));
|
||||
let video = browser.screen().selected_video().unwrap();
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.history(false);
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
video.clone(),
|
||||
Box::new(source(&video, "stale")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
browser.history(true);
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.refresh();
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
video.clone(),
|
||||
Box::new(source(&video, "refreshed-away")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_watch_status_refreshes_filters_and_preserves_other_providers() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert!(browser.state.videos(CollectionKind::Watching).is_empty());
|
||||
assert!(!browser.bindings().iter().any(|(action, _)| {
|
||||
matches!(
|
||||
action,
|
||||
Action::WatchStatusWatching | Action::WatchStatusWatched
|
||||
)
|
||||
}));
|
||||
enter_series(&mut browser);
|
||||
let episode = browser.screen().selected_video().unwrap();
|
||||
let mut youtube = episode.clone();
|
||||
youtube.id = "jNQXAC9IVRw".into();
|
||||
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
|
||||
youtube.uploader_id = Some("@jawed".into());
|
||||
browser
|
||||
.state
|
||||
.save(CollectionKind::Watching, &youtube)
|
||||
.unwrap();
|
||||
browser.save(CollectionKind::WatchLater);
|
||||
browser.preview = Some(PreviewMode::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
browser.preview = None;
|
||||
browser.collection(CollectionKind::Watching);
|
||||
browser.open();
|
||||
assert!(browser.pending.is_some());
|
||||
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.collection(CollectionKind::WatchLater);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &youtube.id)
|
||||
);
|
||||
assert!(!browser.state.is_watched(&youtube.id));
|
||||
let state = State::open(&browser.config_path).unwrap();
|
||||
assert!(state.contains(CollectionKind::Watching, &episode.id));
|
||||
assert!(state.contains(CollectionKind::WatchLater, &episode.id));
|
||||
let overrides: toml_edit::DocumentMut =
|
||||
"[keys.video.status]\nwatched = 'alt+m'\n".parse().unwrap();
|
||||
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
|
||||
browser.collection(CollectionKind::Watching);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::ALT));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert!(state.contains(CollectionKind::Watching, &youtube.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let episode = browser.screen().selected_video().unwrap();
|
||||
let mut youtube = episode.clone();
|
||||
youtube.id = "jNQXAC9IVRw".into();
|
||||
youtube.title = "YouTube title".into();
|
||||
youtube.channel = Some("jawed".into());
|
||||
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
|
||||
youtube.uploader_id = Some("@jawed".into());
|
||||
for kind in [
|
||||
CollectionKind::Saved,
|
||||
CollectionKind::WatchLater,
|
||||
CollectionKind::Watching,
|
||||
] {
|
||||
browser.state.save(kind, &episode).unwrap();
|
||||
browser.state.save(kind, &youtube).unwrap();
|
||||
}
|
||||
browser.collection(CollectionKind::Saved);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.open();
|
||||
assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id));
|
||||
browser.remove(true);
|
||||
assert_eq!(
|
||||
browser.removal.as_ref().unwrap().1.as_slice(),
|
||||
std::slice::from_ref(&episode.id)
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &youtube.id));
|
||||
assert!(!browser.state.contains(CollectionKind::Saved, &episode.id));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::WatchLater, &episode.id)
|
||||
);
|
||||
let (_, report) = browser
|
||||
.state
|
||||
.miruro_session(&episode, &source(&episode, "progress"))
|
||||
.unwrap();
|
||||
let mut output = fs::OpenOptions::new();
|
||||
output.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
output.mode(0o600);
|
||||
}
|
||||
let mut output = output.open(report).unwrap();
|
||||
writeln!(output, "{}", serde_json::json!({"version":2,"event":"ended","media_identity":Identity::from_key(&episode.id).unwrap(),"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true})).unwrap();
|
||||
assert!(browser.state.poll_progress().unwrap());
|
||||
browser.rebuild_collections();
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
assert!(
|
||||
!browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
for kind in [CollectionKind::Watching, CollectionKind::WatchLater] {
|
||||
browser.collection(kind);
|
||||
if matches!(kind, CollectionKind::WatchLater) {
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.remove(true);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
assert!(browser.state.is_watched(&episode.id));
|
||||
}
|
||||
assert!(browser.state.contains(kind, &youtube.id));
|
||||
}
|
||||
let state = State::open(&browser.config_path).unwrap();
|
||||
assert!(state.contains(CollectionKind::Saved, &youtube.id));
|
||||
assert!(state.contains(CollectionKind::Watching, &youtube.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
enter_series(&mut browser);
|
||||
let first = browser.screen().selected_video().unwrap();
|
||||
browser.open();
|
||||
let stale = browser.generation;
|
||||
browser.select(Action::VideoNext);
|
||||
browser.accept(
|
||||
stale,
|
||||
Ok(Response::Source(
|
||||
first.clone(),
|
||||
Box::new(source(&first, "not-selected")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
let selected = browser.screen().selected_video().unwrap();
|
||||
assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7);
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.collection(CollectionKind::Saved);
|
||||
browser.open();
|
||||
browser.pending = None;
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(
|
||||
selected.clone(),
|
||||
Box::new(source(&first, "wrong-identity")),
|
||||
)),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
browser.open();
|
||||
browser.pending = None;
|
||||
let mut invalid = source(&selected, "private-token");
|
||||
invalid
|
||||
.headers
|
||||
.insert("X-Invalid".into(), "private-header\nvalue".into());
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(selected.clone(), Box::new(invalid))),
|
||||
);
|
||||
assert!(sources(&directory).is_empty());
|
||||
assert!(!browser.status.contains("private-token"));
|
||||
assert!(!browser.status.contains("private-header"));
|
||||
for token in ["fresh-one", "fresh-two"] {
|
||||
browser.open();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title)
|
||||
);
|
||||
browser.pending = None; // Deliver the real source response without a network request.
|
||||
browser.accept(
|
||||
browser.generation,
|
||||
Ok(Response::Source(
|
||||
selected.clone(),
|
||||
Box::new(source(&selected, token)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
let written = sources(&directory);
|
||||
assert_eq!(written.len(), 2);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.all(|source| source["identity"]["episode"] == 7
|
||||
&& source["identity"]["media_id"] == "first")
|
||||
);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-one"))
|
||||
);
|
||||
assert!(
|
||||
written
|
||||
.iter()
|
||||
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-two"))
|
||||
);
|
||||
assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress.
|
||||
assert!(
|
||||
!browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &selected.id)
|
||||
);
|
||||
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_modal_keys_and_inputs_do_not_leak_to_main_actions() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
let overrides: toml_edit::DocumentMut =
|
||||
"[keys]\nchannel.tags = 'f2'\ntags.new = 'f3'\ntags.save = 'f4'\ninput.submit = 'f5'\n"
|
||||
.parse()
|
||||
.unwrap();
|
||||
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagList);
|
||||
browser.key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagInput);
|
||||
browser.paste("Safe");
|
||||
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
|
||||
browser.key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::TagList);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.state.shows().is_empty());
|
||||
assert_eq!(browser.index, 0);
|
||||
browser.key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.state.shows()[0].tags,
|
||||
BTreeSet::from(["Safe".into()])
|
||||
);
|
||||
assert!(browser.modal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_show_categories_survive_hover_and_filter_changes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(None);
|
||||
for (id, tag) in [("first", "Drama"), ("second", "Favorites")] {
|
||||
browser
|
||||
.state
|
||||
.save_show(&media(id), &[tag.into()].into())
|
||||
.unwrap();
|
||||
}
|
||||
for filter in [None, Some("no matching category")] {
|
||||
browser.category_picker();
|
||||
for code in [KeyCode::Char('j'), KeyCode::Char(' '), KeyCode::Char('k')] {
|
||||
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
if let Some(filter) = filter {
|
||||
browser.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
browser.paste(filter);
|
||||
}
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let Content::Library { shows, .. } = &browser.screen().content else {
|
||||
panic!("Checked categories must open even without a hovered match");
|
||||
};
|
||||
assert_eq!(
|
||||
shows
|
||||
.iter()
|
||||
.map(|show| show.media.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["first"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_shows_stage_cancel_reopen_and_remove_without_touching_episodes() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
let key = |browser: &mut Browser, code| {
|
||||
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
};
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
key(&mut browser, KeyCode::Char('n'));
|
||||
browser.paste("Drama");
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
key(&mut browser, KeyCode::Esc);
|
||||
assert!(browser.state.shows().is_empty());
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert!(browser.state.shows()[0].tags.is_empty());
|
||||
enter_series(&mut browser);
|
||||
browser.select(Action::VideoNext);
|
||||
browser.save(CollectionKind::Saved);
|
||||
let episode = browser.screen().selected_video().unwrap().id;
|
||||
key(&mut browser, KeyCode::Char('s'));
|
||||
for tag in ["Drama", "Favorites"] {
|
||||
key(&mut browser, KeyCode::Char('n'));
|
||||
browser.paste(tag);
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
}
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert_eq!(browser.state.shows()[0].media.id, "first");
|
||||
browser
|
||||
.state
|
||||
.save_show(&media("second"), &BTreeSet::from(["Drama".into()]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
library_shows(
|
||||
&browser.state,
|
||||
&BTreeSet::from(["Drama".into(), "Favorites".into()])
|
||||
)
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
browser.state = State::open(&browser.config_path).unwrap();
|
||||
key(&mut browser, KeyCode::Char('c'));
|
||||
key(&mut browser, KeyCode::Char('i'));
|
||||
browser.paste("Favorites");
|
||||
key(&mut browser, KeyCode::Enter);
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.open();
|
||||
assert!(
|
||||
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "first")
|
||||
);
|
||||
browser.history(false);
|
||||
browser.library(BTreeSet::new());
|
||||
key(&mut browser, KeyCode::Char('x'));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
browser.history(false);
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &episode));
|
||||
assert_eq!(
|
||||
State::open(&browser.config_path).unwrap().shows()[0]
|
||||
.media
|
||||
.id,
|
||||
"second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(None);
|
||||
assert!(browser.pending.is_none());
|
||||
browser.open();
|
||||
assert_eq!(browser.index, 0);
|
||||
browser.edit_search();
|
||||
browser.paste("Anime\nquery\t\u{1b}[31m");
|
||||
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
browser
|
||||
.input
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.chars()
|
||||
.all(|ch| !ch.is_control())
|
||||
);
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let old = browser.generation;
|
||||
browser.accept(old, Err(io::Error::other("Catalog request failed")));
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
browser.search();
|
||||
browser.accept(browser.generation, Ok(Response::Catalog(Vec::new())));
|
||||
assert!(browser.screen().error.is_none());
|
||||
assert!(matches!(
|
||||
browser.screen().content,
|
||||
Content::Catalog { searched: true, .. }
|
||||
));
|
||||
browser.edit_search();
|
||||
browser.input = Some(" ".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(browser.pending.is_none());
|
||||
assert!(matches!(
|
||||
browser.screen().content,
|
||||
Content::Catalog {
|
||||
searched: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
browser.accept(old, Ok(Response::Catalog(vec![media("stale")])));
|
||||
assert_eq!(browser.screen().len(), 0);
|
||||
assert!(browser.screen().rows.is_empty());
|
||||
fs::write(
|
||||
&browser.config_path,
|
||||
"[keys]\nsearch.edit = 'f8'\napp.quit = 'f9'\n",
|
||||
)
|
||||
.unwrap();
|
||||
browser.config = config::load(&browser.config_path).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
||||
assert!(browser.input.is_some());
|
||||
browser.input = Some("safe".into());
|
||||
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let metadata = miruro_browser::catalog(vec![serde_json::json!({
|
||||
"id":"safe", "title":{"english":"薬屋 Café\u{1b}[31m"},
|
||||
"description":"<p>First\u{1b}[31m</p><p>Second<br/>Third</p>"
|
||||
})])
|
||||
.unwrap();
|
||||
browser.accept(browser.generation, Ok(Response::Catalog(metadata)));
|
||||
assert!(
|
||||
browser
|
||||
.screen()
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.title.chars().all(|ch| !ch.is_control()))
|
||||
);
|
||||
assert!(
|
||||
browser
|
||||
.screen()
|
||||
.description
|
||||
.chars()
|
||||
.all(|ch| !ch.is_control() || ch == '\n')
|
||||
);
|
||||
assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
|
||||
assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)));
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use std::time::Duration;
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
thumbnail.selected = Some("fixture".into());
|
||||
let area = Rect::new(2, 2, 80, 8);
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||
let mut loading_width = 0;
|
||||
terminal
|
||||
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
let (_decode_tx, decode_rx) = mpsc::channel();
|
||||
thumbnail.decoder = Some(decode_rx);
|
||||
let mut decoding_width = 0;
|
||||
terminal
|
||||
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, decoding_width);
|
||||
thumbnail.decoder = None;
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: "fixture".into(),
|
||||
protocol: ThreadProtocol::new(
|
||||
requests_tx,
|
||||
Some(
|
||||
thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
|
||||
),
|
||||
),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
let mut preparing_width = 0;
|
||||
terminal
|
||||
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, preparing_width);
|
||||
terminal
|
||||
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, preparing_width);
|
||||
replies_tx
|
||||
.send(Ok(requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap()))
|
||||
.unwrap();
|
||||
let mut ready_width = 0;
|
||||
terminal
|
||||
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loading_width, ready_width,
|
||||
"right-hand content must not move when the image finishes loading"
|
||||
);
|
||||
thumbnail.cancel();
|
||||
thumbnail.select("fixture");
|
||||
terminal
|
||||
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, ready_width);
|
||||
replies_tx
|
||||
.send(Err("fixture renderer failure".into()))
|
||||
.unwrap();
|
||||
let mut error_width = 0;
|
||||
terminal
|
||||
.draw(|frame| error_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, error_width);
|
||||
assert!(thumbnail.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let url = "https://example.test/cover.png";
|
||||
thumbnail.selected = Some(url.into());
|
||||
thumbnail.aspect = (2, 3);
|
||||
let area = Rect::new(2, 2, 80, 8);
|
||||
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||
let mut loading_width = 0;
|
||||
terminal
|
||||
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, slot.width);
|
||||
// The artwork need not exactly match the placeholder's 2:3 aspect.
|
||||
let protocol = thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: url.into(),
|
||||
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
let response = requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap();
|
||||
replies_tx.send(Ok(response)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!thumbnail.is_loading());
|
||||
let size = thumbnail
|
||||
.image
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.protocol
|
||||
.size_for(Resize::Scale(None), slot)
|
||||
.unwrap();
|
||||
assert!(size.width <= slot.width && size.height <= slot.height);
|
||||
let font = thumbnail.picker.font_size();
|
||||
let width = u32::from(size.width) * u32::from(font.width);
|
||||
let height = u32::from(size.height) * u32::from(font.height);
|
||||
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
|
||||
thumbnail.cancel();
|
||||
thumbnail.select_url(url);
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
assert_eq!(thumbnail.selected.as_deref(), Some(url));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
thumbnail.clear_cache();
|
||||
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
|
||||
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
||||
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
||||
for format in [
|
||||
image::ImageFormat::Jpeg,
|
||||
image::ImageFormat::Png,
|
||||
image::ImageFormat::WebP,
|
||||
] {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgb8(2, 3)
|
||||
.write_to(&mut bytes, format)
|
||||
.unwrap();
|
||||
let image = decode(bytes.into_inner()).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (2, 3));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_discards_queued_decode_completion() {
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (requests_tx, _requests_rx) = mpsc::channel();
|
||||
let (_replies_tx, replies_rx) = mpsc::channel();
|
||||
let protocol = thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1));
|
||||
thumbnail.selected = Some("old-video".into());
|
||||
thumbnail.decoder = Some(rx);
|
||||
assert!(
|
||||
tx.send(Ok(ThumbnailImage {
|
||||
id: "old-video".into(),
|
||||
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
}))
|
||||
.is_ok()
|
||||
);
|
||||
thumbnail.cancel();
|
||||
thumbnail.selected = Some("new-video".into());
|
||||
thumbnail.poll();
|
||||
assert!(thumbnail.image.is_none());
|
||||
assert!(thumbnail.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() {
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let completed = |id: String| {
|
||||
let (requests, _receiver) = mpsc::channel();
|
||||
let (_sender, replies) = mpsc::channel();
|
||||
ThumbnailImage {
|
||||
id,
|
||||
protocol: ThreadProtocol::new(
|
||||
requests,
|
||||
Some(Picker::halfblocks().new_resize_protocol(image::DynamicImage::new_rgb8(1, 1))),
|
||||
),
|
||||
replies,
|
||||
complete: true,
|
||||
}
|
||||
};
|
||||
for index in 0..CACHE_SIZE {
|
||||
thumbnail.image = Some(completed(format!("video{index:06}")));
|
||||
thumbnail.cancel();
|
||||
}
|
||||
thumbnail.image = Some(completed("video000008".into()));
|
||||
thumbnail.selected = Some("video000008".into());
|
||||
// This is the oldest cached image: storing the outgoing image first would evict it.
|
||||
thumbnail.select("video000000");
|
||||
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000");
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
thumbnail.select("video000001");
|
||||
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001");
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
thumbnail.cancel();
|
||||
assert!(
|
||||
!thumbnail
|
||||
.cached
|
||||
.iter()
|
||||
.any(|image| image.id == "video000002")
|
||||
);
|
||||
assert!(
|
||||
thumbnail
|
||||
.cached
|
||||
.iter()
|
||||
.any(|image| image.id == "video000000")
|
||||
);
|
||||
assert!(
|
||||
thumbnail
|
||||
.cached
|
||||
.iter()
|
||||
.any(|image| image.id == "video000008")
|
||||
);
|
||||
thumbnail.clear_cache();
|
||||
assert!(thumbnail.cached.is_empty());
|
||||
assert!(thumbnail.image.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
|
||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let font = thumbnail.picker.font_size();
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
||||
48,
|
||||
36,
|
||||
image::Rgb([240, 40, 70]),
|
||||
));
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.selected = Some("thumbnail".into());
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: "thumbnail".into(),
|
||||
protocol: ThreadProtocol::new(
|
||||
requests_tx,
|
||||
Some(thumbnail.picker.new_resize_protocol(image)),
|
||||
),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
|
||||
for area in [
|
||||
Rect::new(3, 2, 80, 18),
|
||||
Rect::new(7, 4, 30, 8),
|
||||
Rect::new(3, 2, 9, 18),
|
||||
Rect::new(7, 4, 30, 8),
|
||||
] {
|
||||
let mut pending_width = 0;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
pending_width = thumbnail.draw(frame, area);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(thumbnail.is_loading());
|
||||
assert!(
|
||||
!terminal.backend().buffer().content.iter().any(|cell| {
|
||||
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||
}),
|
||||
"a resize must not render stale pixels"
|
||||
);
|
||||
let response = requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap();
|
||||
replies_tx.send(Ok(response)).unwrap();
|
||||
let mut occupied_width = 0;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
occupied_width = thumbnail.draw(frame, area);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!thumbnail.is_loading());
|
||||
assert_eq!(pending_width, occupied_width);
|
||||
let buffer = terminal.backend().buffer();
|
||||
let pixels: Vec<_> = (0..40)
|
||||
.flat_map(|y| (0..100).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| {
|
||||
let cell = &buffer[(x, y)];
|
||||
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||
})
|
||||
.collect();
|
||||
let left = pixels.iter().map(|&(x, _)| x).min().unwrap();
|
||||
let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1;
|
||||
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
|
||||
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
|
||||
assert!(left >= area.x && right <= area.right());
|
||||
assert_eq!(left, area.x);
|
||||
assert_eq!(occupied_width, right - left);
|
||||
assert!(top >= area.y && bottom <= area.bottom());
|
||||
assert!(right - left == area.width || bottom - top == area.height);
|
||||
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
|
||||
let width = u32::from(right - left) * u32::from(font.width);
|
||||
let height = u32::from(bottom - top) * u32::from(font.height);
|
||||
assert!(
|
||||
width.abs_diff(height * 4 / 3)
|
||||
<= u32::from(font.width) + u32::from(font.height) * 4 / 3
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user