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()));
|
||||
|
||||
Reference in New Issue
Block a user