feat: support multiple video selection
This commit is contained in:
+196
-56
@@ -67,6 +67,202 @@ fn app(args: &[&str]) -> App {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"New","timestamp":200}]}"#,
|
||||
true,
|
||||
);
|
||||
let view = &mut app.views[1];
|
||||
view.selection.select(Some(1));
|
||||
view.toggle_checked();
|
||||
view.selection.select(Some(0));
|
||||
view.toggle_checked();
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
|
||||
);
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Newest","timestamp":300}]}"#,
|
||||
false,
|
||||
);
|
||||
let view = &mut app.views[1];
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(
|
||||
view.playback_videos()
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
|
||||
);
|
||||
view.reset();
|
||||
assert!(view.checked.is_empty());
|
||||
// Old rows remain during refresh, but checks made there cannot leak into replacement data.
|
||||
view.toggle_checked();
|
||||
assert!(!view.checked.is_empty());
|
||||
view.accept_page(
|
||||
youtube::search_results(r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Replacement"}]}"#)
|
||||
.unwrap(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(view.checked.is_empty());
|
||||
assert_eq!(view.playback_videos()[0].title, "Replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#,
|
||||
).unwrap().videos;
|
||||
app.views[0].videos = videos.clone();
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.views[0].loaded = true;
|
||||
app.views[1].loaded = true;
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
app.switch_view(1);
|
||||
assert!(app.views[1].checked.is_empty());
|
||||
app.switch_view(0);
|
||||
assert!(app.views[0].checked.contains(&videos[0].id));
|
||||
app.mode = Mode::Insert;
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[0].checked.len(), 1);
|
||||
app.mode = Mode::Normal;
|
||||
for preview in [
|
||||
None,
|
||||
Some(PreviewMode::Info),
|
||||
Some(PreviewMode::Description),
|
||||
] {
|
||||
app.preview = preview;
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert!(app.views[0].checked.contains(&videos[0].id));
|
||||
}
|
||||
app.preview = None;
|
||||
for video in &videos {
|
||||
app.video_state
|
||||
.save(CollectionKind::WatchLater, video)
|
||||
.unwrap();
|
||||
}
|
||||
app.browse_collection(CollectionKind::WatchLater);
|
||||
let index = app.view_index;
|
||||
app.views[index].selection.select(Some(0));
|
||||
app.views[index].toggle_checked();
|
||||
app.views[index].selection.select(Some(1));
|
||||
app.views[index].toggle_checked();
|
||||
let removed = app.views[index].videos[1].id.clone();
|
||||
app.remove_collection();
|
||||
assert!(!app.views[index].checked.contains(&removed));
|
||||
assert_eq!(app.views[index].checked.len(), 1);
|
||||
assert_eq!(app.views[index].playback_videos().len(), 1);
|
||||
app.views[index].watch_filter = WatchFilter::Watched;
|
||||
app.rebuild_collections();
|
||||
assert!(app.views[index].videos.is_empty());
|
||||
assert!(app.views[index].checked.is_empty());
|
||||
app.views[index].watch_filter = WatchFilter::All;
|
||||
app.rebuild_collections();
|
||||
app.views[index].toggle_checked();
|
||||
// Refresh is a replacement even for locally rebuilt collection views.
|
||||
app.load_view();
|
||||
assert!(app.views[index].checked.is_empty());
|
||||
assert_eq!(app.views[0].checked.len(), 1);
|
||||
// An empty category exercises query replacement entirely offline.
|
||||
app.config.categories.insert("Empty".into(), Vec::new());
|
||||
let mut view = View::new("Empty".into(), ViewSource::Category("Empty".into()));
|
||||
view.videos = videos;
|
||||
view.selection.select(Some(0));
|
||||
view.toggle_checked();
|
||||
view.query = "replacement query".into();
|
||||
app.views.push(view);
|
||||
app.view_index = app.views.len() - 1;
|
||||
app.search();
|
||||
assert!(app.views[app.view_index].checked.is_empty());
|
||||
assert!(app.job.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let document = "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
|
||||
app.key_hints = KeyHints::new(&app.config.keys);
|
||||
for context in [
|
||||
Context::Normal,
|
||||
Context::Info,
|
||||
Context::Description,
|
||||
Context::CollectionNormal,
|
||||
Context::CollectionInfo,
|
||||
Context::CollectionDescription,
|
||||
] {
|
||||
for key in ["F20", "F21"] {
|
||||
assert!(app.key_hints.footer(context).contains(key));
|
||||
assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key)));
|
||||
assert!(app.config.keys.help_text().to_string().contains(key));
|
||||
}
|
||||
}
|
||||
app.views[0].videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Checked"},{"id":"rQ_J9WH6CGk","title":"Unchecked"}]}"#,
|
||||
).unwrap().videos;
|
||||
for (index, video) in app.views[0].videos.iter_mut().enumerate() {
|
||||
video.id = format!("offline:{index}");
|
||||
}
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.key(KeyEvent::new(KeyCode::F(20), KeyModifiers::NONE));
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let rendered: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(rendered.contains("[x]"));
|
||||
assert!(rendered.contains("[ ]"));
|
||||
assert!(rendered.contains("1 checked"));
|
||||
app.key(KeyEvent::new(KeyCode::F(21), KeyModifiers::NONE));
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.views[0].videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
|
||||
).unwrap().videos;
|
||||
app.views[0].selection.select(Some(1));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.views[0].toggle_checked();
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.options.player = "/nonexistent/tubular-test-player".into();
|
||||
app.play();
|
||||
assert!(app.player_launch.is_none());
|
||||
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
|
||||
app.options.player = "/usr/bin/true".into();
|
||||
app.play();
|
||||
assert!(app.player_launch.is_some());
|
||||
assert!(app.views[0].checked.is_empty());
|
||||
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
|
||||
app.player_launch = None;
|
||||
app.play();
|
||||
assert!(app.player_launch.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
|
||||
let mut app = app(&["tubular"]);
|
||||
@@ -1849,62 +2045,6 @@ fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_previ
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_removal_hints_follow_collection_views_and_previews() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let document =
|
||||
"[keys]\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n"
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
app.apply_config(config::Config {
|
||||
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
|
||||
..Default::default()
|
||||
});
|
||||
app.views[0].videos = youtube::search_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"}]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.videos;
|
||||
app.views[0].loaded = true;
|
||||
app.views[0].selection.select(Some(0));
|
||||
app.save_collection(CollectionKind::WatchLater);
|
||||
app.save_collection(CollectionKind::Saved);
|
||||
app.remember_description("jNQXAC9IVRw".into(), "Cached description.".into());
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(320, 80)).unwrap();
|
||||
for kind in [
|
||||
None,
|
||||
Some(CollectionKind::WatchLater),
|
||||
Some(CollectionKind::Saved),
|
||||
] {
|
||||
app.switch_view(0);
|
||||
if let Some(kind) = kind {
|
||||
app.browse_collection(kind);
|
||||
}
|
||||
for preview in [
|
||||
None,
|
||||
Some(PreviewMode::Info),
|
||||
Some(PreviewMode::Description),
|
||||
] {
|
||||
app.preview = preview;
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
|
||||
assert_eq!(rendered.contains("F8"), kind.is_some());
|
||||
assert_eq!(rendered.contains("F9"), kind.is_some());
|
||||
if preview == Some(PreviewMode::Info) {
|
||||
let popup = preview_area(buffer.area);
|
||||
let title: String = (popup.x..popup.right())
|
||||
.map(|x| buffer[(x, popup.y)].symbol())
|
||||
.collect();
|
||||
assert_eq!(title.contains("F8"), kind.is_some());
|
||||
assert_eq!(title.contains("F9"), kind.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_removal_snapshots_saved_categories_and_watch_filters_independently() {
|
||||
let root = std::env::temp_dir().join(format!("tubular-collection-bulk-{}", std::process::id()));
|
||||
|
||||
@@ -15,6 +15,51 @@ fn press(
|
||||
bindings.resolve(context, KeyEvent::new(code, modifiers))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_checks_are_configurable_in_views_without_stealing_modal_space() {
|
||||
let defaults = Bindings::default();
|
||||
let overrides = configured("[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n").unwrap();
|
||||
for &context in ALL {
|
||||
for (action, modifiers, override_code) in [
|
||||
(Action::VideoToggle, KeyModifiers::NONE, KeyCode::F(20)),
|
||||
(Action::VideoClear, KeyModifiers::CONTROL, KeyCode::F(21)),
|
||||
] {
|
||||
let expected = VIEW.contains(&context).then_some(action);
|
||||
assert_eq!(
|
||||
press(&defaults, context, KeyCode::Char(' '), modifiers)
|
||||
.filter(|resolved| *resolved == action),
|
||||
expected,
|
||||
);
|
||||
assert_eq!(
|
||||
press(&overrides, context, override_code, KeyModifiers::NONE),
|
||||
expected,
|
||||
);
|
||||
assert_ne!(
|
||||
press(&overrides, context, KeyCode::Char(' '), modifiers),
|
||||
Some(action),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
press(
|
||||
&defaults,
|
||||
Context::TagList,
|
||||
KeyCode::Char(' '),
|
||||
KeyModifiers::NONE
|
||||
),
|
||||
Some(Action::TagToggle),
|
||||
);
|
||||
assert_eq!(
|
||||
press(
|
||||
&defaults,
|
||||
Context::SelectorNormal,
|
||||
KeyCode::Char(' '),
|
||||
KeyModifiers::NONE
|
||||
),
|
||||
Some(Action::SelectorToggle),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_choice_is_configurable_and_normal_only() {
|
||||
let defaults = Bindings::default();
|
||||
|
||||
@@ -574,3 +574,187 @@ fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
|
||||
assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
|
||||
assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn episode_checks_follow_returned_order_and_cached_screen_history() {
|
||||
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.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.select(Action::VideoFirst);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let ids = |browser: &Browser| {
|
||||
browser
|
||||
.screen()
|
||||
.playback_videos()
|
||||
.into_iter()
|
||||
.map(|video| video.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]);
|
||||
assert!(browser.hints().contains("play checked"));
|
||||
assert!(browser.hints().contains("clear checks"));
|
||||
let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(text.contains("2 checked"));
|
||||
assert!(text.contains("[x] Episode 3"));
|
||||
assert!(text.contains("[x] Episode 7"));
|
||||
|
||||
if let Content::Series {
|
||||
data: Some(data), ..
|
||||
} = &mut browser.screen_mut().content
|
||||
{
|
||||
data.reverse();
|
||||
}
|
||||
browser.screen_mut().rebuild_display();
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
browser.history(false);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.history(true);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
browser.screen_mut().selection.select(None);
|
||||
assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]);
|
||||
browser.select(Action::VideoLast);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(ids(&browser), ["miruro:first:3"]);
|
||||
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.refresh();
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
// Checks made against old metadata while loading must not survive replacement.
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let replacement = series(browser.screen().selected_media().unwrap());
|
||||
browser.accept(browser.generation, Ok(Response::Series(replacement)));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(ids(&browser), ["miruro:first:3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_and_saved_show_rows_cannot_be_checked() {
|
||||
let directory = Directory::new();
|
||||
let mut browser = directory.browser(Some("anime"));
|
||||
catalog_results(&mut browser);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert!(browser.screen().playback_videos().is_empty());
|
||||
assert!(
|
||||
!browser
|
||||
.bindings()
|
||||
.iter()
|
||||
.any(|(action, _)| { matches!(action, Action::VideoToggle | Action::VideoClear) })
|
||||
);
|
||||
browser
|
||||
.state
|
||||
.save_show(&media("first"), &BTreeSet::new())
|
||||
.unwrap();
|
||||
browser.library(BTreeSet::new());
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert!(browser.screen().playback_videos().is_empty());
|
||||
enter_series(&mut browser);
|
||||
assert_eq!(browser.screen().len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
browser.edit_search();
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(browser.input.as_deref(), Some(" "));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
browser.input = None;
|
||||
browser.history(false);
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from(["miruro:first:3".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() {
|
||||
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.save(CollectionKind::WatchLater);
|
||||
browser.select(Action::VideoNext);
|
||||
let second = browser.screen().selected_video().unwrap();
|
||||
browser.save(CollectionKind::WatchLater);
|
||||
browser
|
||||
.state
|
||||
.set_watch_status(&first, WatchStatus::Watched)
|
||||
.unwrap();
|
||||
browser.collection(CollectionKind::WatchLater);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.select(Action::VideoNext);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().checked.len(), 2);
|
||||
browser.rebuild_collections();
|
||||
assert_eq!(browser.screen().checked.len(), 2);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
browser.screen().checked,
|
||||
BTreeSet::from([second.id.clone()])
|
||||
);
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, first.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.remove(false);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert!(browser.screen().playback_videos().is_empty());
|
||||
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.screen().len(), 1);
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, second.id);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
|
||||
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.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.select(Action::VideoNext);
|
||||
let hovered = browser.screen().selected_video().unwrap();
|
||||
browser.save(CollectionKind::Saved);
|
||||
browser.set_watch_status(WatchStatus::Watching);
|
||||
assert!(!browser.state.contains(CollectionKind::Saved, &first.id));
|
||||
assert!(!browser.state.contains(CollectionKind::Watching, &first.id));
|
||||
assert!(browser.state.contains(CollectionKind::Saved, &hovered.id));
|
||||
assert!(
|
||||
browser
|
||||
.state
|
||||
.contains(CollectionKind::Watching, &hovered.id)
|
||||
);
|
||||
browser.open();
|
||||
assert!(browser.screen().error.is_some());
|
||||
assert_eq!(browser.screen().checked, BTreeSet::from([first.id.clone()]));
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, first.id);
|
||||
browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
browser.options.player = "/usr/bin/true".into();
|
||||
browser.open();
|
||||
assert!(browser.screen().error.is_none());
|
||||
assert!(browser.player.is_some());
|
||||
assert!(browser.screen().checked.is_empty());
|
||||
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
|
||||
}
|
||||
|
||||
@@ -1394,3 +1394,58 @@ fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() {
|
||||
assert!(state.poll_progress().unwrap());
|
||||
assert!(state.is_watched(&video.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_launch_rejects_invalid_and_duplicate_batch_identities_before_creating_sessions() {
|
||||
use crate::youtube::PlayerLaunch;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
for id in [
|
||||
"",
|
||||
"--help",
|
||||
"jNQXAC9IVRw&list=other",
|
||||
"日本語",
|
||||
"miruro:opaque_Show-1:0",
|
||||
"miruro:opaque_Show-1:01",
|
||||
"miruro:opaque_Show-1:2147483648",
|
||||
"miruro:../other:1",
|
||||
"miruro:opaque_Show-1:1&ep=2",
|
||||
"miruro-series:opaque_Show-1",
|
||||
] {
|
||||
let mut invalid_video = video();
|
||||
invalid_video.id = id.into();
|
||||
assert!(matches!(
|
||||
PlayerLaunch::start(&[video(), invalid_video], OsStr::new("terebi"), &mut state),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
for videos in [vec![], vec![video(), video()]] {
|
||||
assert!(matches!(
|
||||
PlayerLaunch::start(&videos, OsStr::new("terebi"), &mut state),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
assert!(!state.sessions.exists());
|
||||
assert!(state.streams.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_batch_prevalidates_snapshots_and_session_capacity() {
|
||||
let directory = TestDirectory::new();
|
||||
let mut state = State::open(&directory.config()).unwrap();
|
||||
let mut invalid_video = miruro_video(2);
|
||||
invalid_video.title = "invalid\nmetadata".into();
|
||||
assert!(state.progress_files(&[video(), invalid_video]).is_err());
|
||||
assert!(!state.sessions.exists());
|
||||
assert!(state.streams.is_empty());
|
||||
for _ in 0..MAX_STREAMS - 1 {
|
||||
state
|
||||
.streams
|
||||
.push(ProgressStream::new(directory.0.join("unused.jsonl")));
|
||||
}
|
||||
assert!(state.progress_files(&[video(), miruro_video(2)]).is_err());
|
||||
assert!(!state.sessions.exists());
|
||||
assert_eq!(state.streams.len(), MAX_STREAMS - 1);
|
||||
}
|
||||
|
||||
@@ -533,27 +533,6 @@ mod subprocesses {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_launch_rejects_invalid_provider_ids_before_registration_or_spawning() {
|
||||
for id in [
|
||||
"",
|
||||
"--help",
|
||||
"jNQXAC9IVRw&list=other",
|
||||
"日本語",
|
||||
"miruro:opaque_Show-1:0",
|
||||
"miruro:opaque_Show-1:01",
|
||||
"miruro:opaque_Show-1:2147483648",
|
||||
"miruro:../other:1",
|
||||
"miruro:opaque_Show-1:1&ep=2",
|
||||
"miruro-series:opaque_Show-1",
|
||||
] {
|
||||
assert!(matches!(
|
||||
PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_open_rejects_unsafe_video_ids_before_spawning() {
|
||||
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
|
||||
|
||||
Reference in New Issue
Block a user