6081 lines
237 KiB
Rust
6081 lines
237 KiB
Rust
use super::*;
|
|
|
|
impl App {
|
|
fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self {
|
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let path = std::env::temp_dir()
|
|
.join(format!(
|
|
"tubular-ui-state-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
))
|
|
.join("config.toml");
|
|
Self::with_state(
|
|
options,
|
|
config_path,
|
|
config,
|
|
video_state::State::open(&path).unwrap(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn startup_provider_selection_preserves_query_and_handles_boundaries_and_cancel() {
|
|
use std::ops::ControlFlow::{Break, Continue};
|
|
let mut selected = 0;
|
|
assert_eq!(
|
|
provider_selection(&mut selected, Some(Action::SelectorPrevious)),
|
|
Continue(())
|
|
);
|
|
for (action, miruro) in [(Action::SelectorOpen, false), (Action::SelectorNext, true)] {
|
|
if action == Action::SelectorNext {
|
|
assert_eq!(
|
|
provider_selection(&mut selected, Some(action)),
|
|
Continue(())
|
|
);
|
|
assert_eq!(
|
|
provider_selection(&mut selected, Some(action)),
|
|
Continue(())
|
|
);
|
|
}
|
|
let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen))
|
|
else {
|
|
panic!("Selected provider must open");
|
|
};
|
|
assert_eq!(choice, miruro);
|
|
let mut options = Options::parse_from(["tubular", "initial query"]);
|
|
options.miruro = choice;
|
|
assert_eq!(options.query.as_deref(), Some("initial query"));
|
|
assert_eq!(options.miruro, miruro);
|
|
if !miruro {
|
|
let app = App::new(options, PathBuf::new(), config::Config::default());
|
|
assert_eq!(app.views[0].query, "initial query");
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
}
|
|
}
|
|
for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] {
|
|
assert_eq!(provider_selection(&mut selected, Some(action)), Break(None));
|
|
}
|
|
assert_eq!(provider_selection(&mut selected, None), Continue(()));
|
|
}
|
|
|
|
fn app(args: &[&str]) -> App {
|
|
App::new(
|
|
Options::parse_from(args),
|
|
PathBuf::new(),
|
|
config::Config::default(),
|
|
)
|
|
}
|
|
|
|
#[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[keys.video]\nenqueue = 'f22'\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", "F22"] {
|
|
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("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() {
|
|
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
|
|
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.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
|
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.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
|
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.key(KeyEvent::new(KeyCode::Enter, modifiers));
|
|
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"]);
|
|
let template =
|
|
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Listing title"}]}"#)
|
|
.unwrap()
|
|
.videos
|
|
.remove(0);
|
|
app.views[0].videos = (0..12)
|
|
.map(|index| {
|
|
let mut video = template.clone();
|
|
// Non-YouTube IDs keep this rendering check entirely offline.
|
|
video.id = format!("offline:{index}");
|
|
video.title = format!("Row {index:02}");
|
|
video
|
|
})
|
|
.collect();
|
|
app.views[0].selection.select(Some(9));
|
|
let now = Instant::now();
|
|
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw_at(frame, Duration::ZERO, now))
|
|
.unwrap();
|
|
assert_eq!(app.views[0].selection.offset(), 3);
|
|
assert_eq!(
|
|
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
|
|
"offline:9"
|
|
);
|
|
assert!(!app.titles.active());
|
|
|
|
terminal.backend_mut().resize(80, 17);
|
|
terminal
|
|
.draw(|frame| {
|
|
app.draw_at(
|
|
frame,
|
|
Duration::from_millis(20),
|
|
now + Duration::from_millis(20),
|
|
)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(app.views[0].selection.offset(), 7);
|
|
assert_eq!(
|
|
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
|
|
"offline:9"
|
|
);
|
|
let rendered: String = terminal
|
|
.backend()
|
|
.buffer()
|
|
.content
|
|
.iter()
|
|
.map(|cell| cell.symbol())
|
|
.collect();
|
|
assert!(rendered.contains("Row 09"));
|
|
|
|
app.views[0].selection.select(Some(0));
|
|
terminal
|
|
.draw(|frame| {
|
|
app.draw_at(
|
|
frame,
|
|
Duration::from_millis(40),
|
|
now + Duration::from_millis(40),
|
|
)
|
|
})
|
|
.unwrap();
|
|
assert_eq!(app.views[0].selection.offset(), 0);
|
|
assert_eq!(
|
|
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
|
|
"offline:0"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn youtube_collections_isolate_mixed_provider_rows_and_removal_ids() {
|
|
let mut app = app(&["tubular"]);
|
|
let youtube = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"YouTube","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@creator"}]}"#,
|
|
).unwrap().videos.remove(0);
|
|
let mut miruro = youtube.clone();
|
|
miruro.id = "miruro:catalog_ID:1".into();
|
|
miruro.title = "Miruro episode".into();
|
|
miruro.channel = Some("Miruro".into());
|
|
miruro.channel_id = None;
|
|
miruro.uploader_id = None;
|
|
for kind in [
|
|
CollectionKind::WatchLater,
|
|
CollectionKind::Saved,
|
|
CollectionKind::Watching,
|
|
] {
|
|
app.video_state.save(kind, &youtube).unwrap();
|
|
app.video_state.save(kind, &miruro).unwrap();
|
|
app.browse_collection(kind);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
assert_eq!(app.views[app.view_index].videos[0].id, youtube.id);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(0));
|
|
app.remove_collection_bulk();
|
|
assert_eq!(
|
|
app.collection_removal.as_ref().unwrap().ids.as_slice(),
|
|
std::slice::from_ref(&youtube.id)
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert!(!app.video_state.contains(kind, &youtube.id));
|
|
assert!(app.video_state.contains(kind, &miruro.id));
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
app.video_state.save(kind, &youtube).unwrap();
|
|
app.rebuild_collections();
|
|
app.remove_collection();
|
|
assert!(!app.video_state.contains(kind, &youtube.id));
|
|
assert!(app.video_state.contains(kind, &miruro.id));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn watching_direct_bindings_respect_modals_and_offline_history() {
|
|
for (label, overrides, binding) in [
|
|
("default", "", Some(KeyCode::Char('C'))),
|
|
(
|
|
"remapped",
|
|
"[keys]\nview.watching.browse = 'f8'\n",
|
|
Some(KeyCode::F(8)),
|
|
),
|
|
("disabled", "[keys]\nview.watching.browse = []\n", None),
|
|
] {
|
|
let mut app = rename_app(&format!("watching-{label}"), overrides);
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
}
|
|
app.mode = Mode::Insert;
|
|
rename_press(&mut app, KeyCode::Char('C'));
|
|
assert_eq!(app.views[0].query, "C");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
app.open_browser(BrowserKind::Categories);
|
|
rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C')));
|
|
assert!(app.browser.is_some());
|
|
assert_eq!(app.view_index, 0);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Char('?'));
|
|
rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C')));
|
|
assert!(app.help_popup.is_some());
|
|
assert_eq!(app.view_index, 0);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
if binding != Some(KeyCode::Char('C')) {
|
|
rename_press(&mut app, KeyCode::Char('C'));
|
|
assert_eq!(app.view_index, 0);
|
|
}
|
|
if let Some(binding) = binding {
|
|
rename_press(&mut app, binding);
|
|
let index = app.view_index;
|
|
assert_eq!(app.views[index].source, ViewSource::Watching);
|
|
assert_eq!(app.temporary_view, Some(ViewSource::Watching));
|
|
assert_eq!(app.key_context(), Context::CollectionNormal);
|
|
assert!(app.browser.is_none());
|
|
app.load_page();
|
|
let config = std::mem::take(&mut app.config);
|
|
app.apply_config(config);
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Watching);
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(app.view_index, index);
|
|
rename_press(&mut app, KeyCode::Char('+'));
|
|
assert_eq!(app.view_index, 0);
|
|
rename_press(&mut app, binding);
|
|
assert_eq!(app.view_index, index);
|
|
assert!(app.job.is_none());
|
|
assert!(!app.views[index].has_more);
|
|
}
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn watching_progress_preserves_selection_and_removal_is_collection_only() {
|
|
use std::io::Write;
|
|
fn report(path: &std::path::Path, id: &str, position: u32) {
|
|
let mut options = std::fs::OpenOptions::new();
|
|
options.append(true).create(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.mode(0o600);
|
|
}
|
|
writeln!(
|
|
options.open(path).unwrap(),
|
|
"{{\"version\":1,\"event\":\"progress\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":true}}"
|
|
).unwrap();
|
|
}
|
|
let mut app = rename_app("watching-progress", "");
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
}
|
|
let videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Third","uploader_id":"@third"}]}"#,
|
|
).unwrap().videos;
|
|
let reports: Vec<_> = videos
|
|
.iter()
|
|
.map(|video| {
|
|
app.video_state.save(CollectionKind::Saved, video).unwrap();
|
|
app.video_state
|
|
.save(CollectionKind::WatchLater, video)
|
|
.unwrap();
|
|
let path = app.video_state.progress_file(video).unwrap();
|
|
video_state::register_identity(&path, &video.id).unwrap();
|
|
report(&path, &video.id, 1);
|
|
path
|
|
})
|
|
.collect();
|
|
app.update();
|
|
rename_press(&mut app, KeyCode::Char('C'));
|
|
assert_eq!(app.views[app.view_index].videos.len(), 3);
|
|
let second = app.views[app.view_index]
|
|
.videos
|
|
.iter()
|
|
.position(|video| video.id == videos[1].id)
|
|
.unwrap();
|
|
app.views[app.view_index].selection.select(Some(second));
|
|
app.preview = Some(PreviewMode::Description);
|
|
report(&reports[0], &videos[0].id, 95);
|
|
app.update();
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.videos.len(), 2);
|
|
assert_eq!(
|
|
view.videos[view.selection.selected().unwrap()].id,
|
|
videos[1].id
|
|
);
|
|
assert_eq!(app.preview, Some(PreviewMode::Description));
|
|
report(&reports[1], &videos[1].id, 95);
|
|
app.update();
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
assert!(app.preview.is_none());
|
|
assert!(app.status.contains("1 videos"));
|
|
rename_press(&mut app, KeyCode::Char('x'));
|
|
assert!(app.video_state.videos(CollectionKind::Watching).is_empty());
|
|
report(&reports[2], &videos[2].id, 2);
|
|
app.update();
|
|
assert!(app.video_state.videos(CollectionKind::Watching).is_empty());
|
|
let fresh = app.video_state.progress_file(&videos[2]).unwrap();
|
|
video_state::register_identity(&fresh, &videos[2].id).unwrap();
|
|
report(&fresh, &videos[2].id, 1);
|
|
app.update();
|
|
rename_press(&mut app, KeyCode::Char('X'));
|
|
assert_eq!(
|
|
app.collection_removal.as_ref().unwrap().ids,
|
|
[videos[2].id.clone()]
|
|
);
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.collection_removal.is_some());
|
|
rename_press(&mut app, KeyCode::Char('y'));
|
|
assert!(app.video_state.videos(CollectionKind::Watching).is_empty());
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 3);
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3);
|
|
assert!(app.video_state.is_watched(&videos[0].id));
|
|
assert!(app.video_state.is_watched(&videos[1].id));
|
|
assert!(!app.video_state.is_watched(&videos[2].id));
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn direct_jumps_reuse_cached_views_and_navigation_with_configured_bindings() {
|
|
for (document, search, feed) in [
|
|
("", KeyCode::Char('/'), KeyCode::Char('F')),
|
|
(
|
|
"[keys]\nview.search = 'f6'\nview.feed = 'f7'\n",
|
|
KeyCode::F(6),
|
|
KeyCode::F(7),
|
|
),
|
|
] {
|
|
let mut app = app(&["tubular", "global draft"]);
|
|
if !document.is_empty() {
|
|
let document = document.parse::<toml_edit::DocumentMut>().unwrap();
|
|
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
|
|
}
|
|
let videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"#
|
|
).unwrap().videos;
|
|
app.views.push(View::new(
|
|
"Temporary".into(),
|
|
ViewSource::Channel("creator".into()),
|
|
));
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
view.videos = videos.clone();
|
|
view.selection.select(Some(1));
|
|
view.next_start = 61;
|
|
}
|
|
app.view_index = 2;
|
|
app.preview = Some(PreviewMode::Description);
|
|
rename_press(&mut app, feed);
|
|
assert_eq!(app.view_index, 1);
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
assert!(app.preview.is_none());
|
|
rename_press(&mut app, search);
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views[0].query, "global draft");
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(app.view_index, 2);
|
|
rename_press(&mut app, KeyCode::Char('+'));
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Char('+'));
|
|
assert_eq!(app.view_index, 0);
|
|
for view in &app.views {
|
|
assert_eq!(view.videos[1].id, "l-GaaivVJCE");
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.next_start, 61);
|
|
}
|
|
assert!(app.job.is_none());
|
|
for source in [
|
|
ViewSource::Channel("creator".into()),
|
|
ViewSource::SavedVideos(None),
|
|
ViewSource::WatchLater(None),
|
|
ViewSource::Watching,
|
|
] {
|
|
app.views[2].source = source;
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
app.view_index = 2;
|
|
app.preview = preview;
|
|
rename_press(&mut app, search);
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
assert!(app.preview.is_none());
|
|
rename_press(&mut app, feed);
|
|
assert_eq!(app.view_index, 1);
|
|
}
|
|
}
|
|
app.view_index = 0;
|
|
if !document.is_empty() {
|
|
app.view_index = 2;
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.view_index, 2, "remapping replaces both defaults");
|
|
}
|
|
let document = "[keys]\nview.search = []\nview.feed = []\n"
|
|
.parse::<toml_edit::DocumentMut>()
|
|
.unwrap();
|
|
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
|
|
app.view_index = 2;
|
|
rename_press(&mut app, search);
|
|
rename_press(&mut app, feed);
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.view_index, 2);
|
|
assert!(app.job.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn direct_jump_keys_remain_literal_or_inert_in_modals() {
|
|
let mut app = app(&["tubular"]);
|
|
app.views[1].loaded = true;
|
|
app.view_index = 1;
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.views[0].query, "/F");
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
for kind in [BrowserKind::Categories, BrowserKind::Channels] {
|
|
app.open_browser(kind);
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "/F");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
}
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@creator".into(),
|
|
channel: "creator".into(),
|
|
},
|
|
"Creator".into(),
|
|
Default::default(),
|
|
));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.category_dialog.as_ref().unwrap().query, "/F");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Char('n'));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("/F")
|
|
);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Char('?'));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert!(app.help_popup.is_some());
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
app.collection_removal = Some(CollectionRemoval {
|
|
kind: CollectionKind::Saved,
|
|
ids: Vec::new(),
|
|
category: None,
|
|
filter: None,
|
|
text: "Confirm removal".into(),
|
|
});
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert!(app.collection_removal.is_some());
|
|
assert_eq!(app.view_index, 1);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
let mut group = View::new("Combined".into(), ViewSource::CategoryGroup(vec![]));
|
|
group.loaded = true;
|
|
app.views.push(group);
|
|
let group_index = app.views.len() - 1;
|
|
app.view_index = group_index;
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('F'));
|
|
assert_eq!(app.category_name_input.as_ref().unwrap().name, "/F");
|
|
assert_eq!(app.view_index, group_index);
|
|
}
|
|
|
|
#[test]
|
|
fn category_and_group_queries_own_drafts_pages_and_errors_without_touching_search() {
|
|
for source in [
|
|
ViewSource::Category("Alpha".into()),
|
|
ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]),
|
|
] {
|
|
let mut app = app(&["tubular", "global draft"]);
|
|
app.views[0].submitted_query = "global submitted".into();
|
|
app.views[0].loaded = true;
|
|
app.views[0].next_start = 61;
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Global cached result"}]}"#,
|
|
)
|
|
.unwrap()
|
|
.videos;
|
|
app.views[0].selection.select(Some(0));
|
|
app.apply_config(config::Config {
|
|
categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(),
|
|
..Default::default()
|
|
});
|
|
app.browse_view("Scoped creators".into(), source.clone());
|
|
let index = app.view_index;
|
|
let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached"}]}"#;
|
|
app.views[index].feed_cursor.plan(1).unwrap();
|
|
app.views[index]
|
|
.accept_page(feed_fixture(output).unwrap(), true)
|
|
.unwrap();
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.paste(" robotics 日本語 ");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(app.views[index].submitted_query, "robotics 日本語");
|
|
assert_eq!(app.view_index, index);
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
assert!(app.views[index].videos.is_empty());
|
|
assert_eq!(app.views[index].selection.selected(), None);
|
|
assert_eq!(app.views[index].next_start, 1);
|
|
assert_eq!(app.views[index].feed_cursor.plan(1).unwrap()[0].start, 1);
|
|
app.finish_page(Err("Scoped request failed".into()));
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(app.view_index, index);
|
|
assert!(app.views[index].page_error.is_some());
|
|
assert!(
|
|
app.job.is_none(),
|
|
"repeated failed query must not retry globally"
|
|
);
|
|
app.views[index]
|
|
.accept_page(feed_fixture(output).unwrap(), true)
|
|
.unwrap();
|
|
let next_start = app.views[index].next_start;
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(app.views[index].videos[0].id, "jNQXAC9IVRw");
|
|
assert_eq!(app.views[index].next_start, next_start);
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.paste(" draft");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert_eq!(app.views[index].submitted_query, "robotics 日本語");
|
|
assert!(app.views[index].query.ends_with(" draft"));
|
|
let mut terminal =
|
|
ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
|
|
assert!(text.contains("Category search"));
|
|
assert!(!text.contains("YouTube search"));
|
|
rename_press(&mut app, KeyCode::Char('/'));
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
assert!(app.views[index].query.ends_with(" draft"));
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(app.view_index, index);
|
|
assert!(app.views[index].submitted_query.is_empty());
|
|
assert!(app.views[index].videos.is_empty());
|
|
assert_eq!(app.views[index].next_start, 1);
|
|
assert!(app.job.is_none());
|
|
assert_eq!(app.views[0].query, "global draft");
|
|
assert_eq!(app.views[0].submitted_query, "global submitted");
|
|
assert_eq!(app.views[0].next_start, 61);
|
|
assert_eq!(app.views[0].videos[0].id, "l-GaaivVJCE");
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn other_sources_submit_to_general_search_and_blank_global_query_is_a_noop() {
|
|
let mut app = app(&["tubular", " cached global "]);
|
|
app.views[0].submitted_query = "cached global".into();
|
|
app.views[0].loaded = true;
|
|
app.views[0].next_start = 61;
|
|
for source in [
|
|
ViewSource::Feed,
|
|
ViewSource::Channel("creator".into()),
|
|
ViewSource::WatchLater(None),
|
|
ViewSource::SavedVideos(None),
|
|
] {
|
|
let mut view = View::new("Other".into(), source);
|
|
view.loaded = true;
|
|
app.views.push(view);
|
|
app.view_index = app.views.len() - 1;
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views[0].next_start, 61);
|
|
assert!(app.job.is_none());
|
|
}
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.views[0].query.is_empty());
|
|
assert_eq!(app.views[0].submitted_query, "cached global");
|
|
assert_eq!(app.views[0].next_start, 61);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
// Unrelated UI fixtures still parse real feed envelopes with a shared-budget plan.
|
|
fn feed_fixture(output: &str) -> Result<Page, String> {
|
|
let channels = serde_json::Deserializer::from_str(output)
|
|
.into_iter::<serde_json::Value>()
|
|
.count();
|
|
let requests = youtube::FeedCursor::default().plan(channels).unwrap();
|
|
youtube::feed_results(output, &requests)
|
|
}
|
|
|
|
fn finish_feed_fixture(app: &mut App, output: &str, full: bool) {
|
|
let view = &mut app.views[app.view_index];
|
|
let channels = view
|
|
.source
|
|
.channels(&app.config, &app.feed_channels, &view.group_channels)
|
|
.len();
|
|
view.feed_requests = view.feed_cursor.plan(channels).unwrap();
|
|
let mut envelopes: Vec<_> = serde_json::Deserializer::from_str(output)
|
|
.into_iter::<serde_json::Value>()
|
|
.map(Result::unwrap)
|
|
.collect();
|
|
assert!(envelopes.len() <= view.feed_requests.len());
|
|
envelopes.resize_with(
|
|
view.feed_requests.len(),
|
|
|| serde_json::json!({"entries":[]}),
|
|
);
|
|
if full {
|
|
for (envelope, request) in envelopes.iter_mut().zip(&view.feed_requests) {
|
|
let entries = envelope["entries"].as_array_mut().unwrap();
|
|
assert!(entries.len() <= request.count);
|
|
entries.resize(request.count, serde_json::Value::Null);
|
|
}
|
|
}
|
|
let output = envelopes
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
app.finish_page(Ok(output));
|
|
}
|
|
|
|
fn choose_combined(app: &mut App, key: KeyEvent) {
|
|
app.key(key);
|
|
while app
|
|
.browser
|
|
.as_ref()
|
|
.unwrap()
|
|
.selection
|
|
.selected()
|
|
.unwrap_or(0)
|
|
!= 0
|
|
{
|
|
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
}
|
|
|
|
fn rename_app(label: &str, keys: &str) -> App {
|
|
let root = std::env::temp_dir().join(format!("tubular-rename-{label}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let path = root.join("config.toml");
|
|
std::fs::write(&path, format!(
|
|
"[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@Alpha']\nOther = []\n{keys}"
|
|
)).unwrap();
|
|
App::new(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config::load(&path).unwrap(),
|
|
)
|
|
}
|
|
|
|
fn rename_press(app: &mut App, code: KeyCode) {
|
|
app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
|
}
|
|
|
|
fn rename_to(app: &mut App, name: &str) {
|
|
app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT));
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
app.paste(name);
|
|
rename_press(app, KeyCode::Enter);
|
|
assert!(app.category_name_input.is_none());
|
|
}
|
|
|
|
fn group_app(label: &str, keys: &str) -> App {
|
|
let mut app = rename_app(&format!("group-{label}"), keys);
|
|
let config_text = |members: &str| {
|
|
format!(
|
|
"# keep this comment\n[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@Alias' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@Beta' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n{members}\n{keys}"
|
|
)
|
|
};
|
|
std::fs::write(
|
|
&app.config_path,
|
|
config_text("Alpha = []\nBeta = []\nGamma = []"),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Down);
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
std::fs::write(
|
|
&app.config_path,
|
|
config_text("Alpha = ['@Alpha']\nBeta = ['@Alias', '@Beta']\nGamma = []"),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
app.views[app.view_index].loaded = true;
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn category_checks_survive_filters_and_enter_uses_zero_one_or_many_choices() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Beta".into(), vec![]),
|
|
("Gamma".into(), vec![]),
|
|
]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.paste("beta");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
app.paste("no match");
|
|
assert!(app.browser.as_ref().unwrap().matches.is_empty());
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
let combined = app.views[app.view_index].source.clone();
|
|
assert_eq!(
|
|
combined,
|
|
ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()])
|
|
);
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
assert_eq!(
|
|
app.browser
|
|
.as_ref()
|
|
.unwrap()
|
|
.checked
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>(),
|
|
["Alpha", "Beta"]
|
|
);
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Beta".into())
|
|
);
|
|
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Down);
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Gamma".into())
|
|
);
|
|
let before = app.views[app.view_index].source.clone();
|
|
let history = app.view_history.clone();
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert_eq!(app.views[app.view_index].source, before);
|
|
assert_eq!(app.view_history, history);
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
assert!(app.browser.as_ref().unwrap().checked.is_empty());
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.paste("no match");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.browser.is_some());
|
|
assert_eq!(app.views[app.view_index].source, before);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_feed_deduplicates_overlapping_rows_and_restores_pages_through_history() {
|
|
let mut app = group_app("pages", "");
|
|
let group = app.views[app.view_index].source.clone();
|
|
assert_eq!(
|
|
app.views[app.view_index].group_channels,
|
|
[
|
|
"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A",
|
|
"https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA",
|
|
]
|
|
);
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared","timestamp":300},{"id":"l-GaaivVJCE","title":"Old","timestamp":100}]}
|
|
{"entries":[{"id":"jNQXAC9IVRw","title":"Shared again","timestamp":300},{"id":"rQ_J9WH6CGk","title":"Middle","timestamp":200}]}"#,
|
|
true,
|
|
);
|
|
assert_eq!(
|
|
app.views[app.view_index]
|
|
.videos
|
|
.iter()
|
|
.map(|video| video.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE"]
|
|
);
|
|
rename_press(&mut app, KeyCode::Down);
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Last","timestamp":50}]}"#,
|
|
false,
|
|
);
|
|
let next_start = app.views[app.view_index].next_start;
|
|
let names = vec!["Alpha".into(), "Gamma".into()];
|
|
let mut replacement = View::new(group_name(&names), ViewSource::CategoryGroup(names));
|
|
replacement.group_channels = app.config.categories["Alpha"].clone();
|
|
replacement.loaded = true;
|
|
app.views.push(replacement);
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Down);
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Down);
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::CategoryGroup(vec!["Alpha".into(), "Gamma".into()])
|
|
);
|
|
assert_eq!(
|
|
app.visible_views()
|
|
.filter(|(_, view)| matches!(view.source, ViewSource::CategoryGroup(_)))
|
|
.count(),
|
|
1
|
|
);
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, group);
|
|
assert_eq!(view.videos.len(), 4);
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.next_start, next_start);
|
|
assert!(app.job.is_none());
|
|
rename_press(&mut app, KeyCode::Char('+'));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::CategoryGroup(vec!["Alpha".into(), "Gamma".into()])
|
|
);
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn combined_cache_resets_only_for_changed_resolved_union_and_drops_deleted_members() {
|
|
let mut app = group_app("config", "");
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#,
|
|
false,
|
|
);
|
|
let group = app.views[app.view_index].source.clone();
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
std::fs::write(
|
|
&app.config_path,
|
|
original
|
|
.replace("Alpha = ['@Alpha']", "Alpha = ['@Beta']")
|
|
.replace("Beta = ['@Alias', '@Beta']", "Beta = ['@Alpha']"),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, group);
|
|
assert_eq!(view.videos.len(), 1);
|
|
assert_eq!(view.next_start, PAGE_SIZE + 1);
|
|
std::fs::write(
|
|
&app.config_path,
|
|
original.replace("Beta = ['@Alias', '@Beta']", "Beta = ['@Alias']"),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, group);
|
|
assert_eq!(view.videos[0].title, "Cached");
|
|
assert!(!view.loaded);
|
|
assert_eq!(view.next_start, 1);
|
|
assert_eq!(view.group_channels.len(), 1);
|
|
app.views[app.view_index].loaded = true;
|
|
app.views[0].loaded = true;
|
|
app.switch_view(0);
|
|
std::fs::write(
|
|
&app.config_path,
|
|
original.replace("Beta = ['@Alias', '@Beta']\n", ""),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
assert!(!app.views.iter().any(|view| view.source == group));
|
|
assert!(app.temporary_view.is_none());
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn checked_category_rename_preserves_canonical_group_pin_selection_and_history() {
|
|
let mut app = group_app("rename", "");
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#,
|
|
false,
|
|
);
|
|
app.views[app.view_index].query = "draft astronomy".into();
|
|
app.views[app.view_index].submitted_query = "astronomy".into();
|
|
let old = app.views[app.view_index].source.clone();
|
|
app.view_history.push(old.clone());
|
|
app.forward_history.push(old);
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.paste("Alpha");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
std::fs::write(&app.config_path, format!("{original}Extra = []\n")).unwrap();
|
|
rename_press(&mut app, KeyCode::Char('p'));
|
|
assert_eq!(app.browser.as_ref().unwrap().checked.len(), 2);
|
|
assert!(app.browser.as_ref().unwrap().checked.contains("Alpha"));
|
|
assert!(!app.config.pinned_categories.contains(&"Alpha".into()));
|
|
rename_press(&mut app, KeyCode::Char('p'));
|
|
rename_to(&mut app, "Zeta");
|
|
let renamed = ViewSource::CategoryGroup(vec!["Beta".into(), "Zeta".into()]);
|
|
assert_eq!(app.views[app.view_index].source, renamed);
|
|
assert_eq!(app.temporary_view, Some(renamed.clone()));
|
|
assert!(app.view_history.contains(&renamed));
|
|
assert!(app.forward_history.contains(&renamed));
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
assert_eq!(app.views[app.view_index].query, "draft astronomy");
|
|
assert_eq!(app.views[app.view_index].submitted_query, "astronomy");
|
|
assert_eq!(app.config.pinned_categories, ["Zeta"]);
|
|
let browser = app.browser.as_ref().unwrap();
|
|
assert_eq!(
|
|
browser
|
|
.checked
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>(),
|
|
["Beta", "Zeta"]
|
|
);
|
|
assert_eq!(browser.selected_choice().unwrap().0, "Zeta");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert_eq!(app.views[app.view_index].source, renamed);
|
|
assert!(app.job.is_none());
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn saving_combined_view_copies_snapshot_and_preserves_cached_state_without_pinning() {
|
|
let mut app = group_app("save", "");
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":200},{"id":"l-GaaivVJCE","title":"Second","timestamp":100}]}"#,
|
|
true,
|
|
);
|
|
rename_press(&mut app, KeyCode::Down);
|
|
let source = app.views[app.view_index].source.clone();
|
|
let channels = app.views[app.view_index].group_channels.clone();
|
|
let next_requests = app.views[app.view_index]
|
|
.feed_cursor
|
|
.plan(channels.len())
|
|
.unwrap();
|
|
app.views[app.view_index].feed_requests = next_requests.clone();
|
|
app.views[app.view_index].prefetch_requested = true;
|
|
app.views[app.view_index].has_more = true;
|
|
app.views[app.view_index].page_error = Some("keep failure".into());
|
|
app.views[app.view_index].query = "draft physics".into();
|
|
app.views[app.view_index].submitted_query = "physics".into();
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
app.paste("Saved union");
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original);
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
for invalid in [" ", "Alpha", "Bad\nname"] {
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
app.category_name_input.as_mut().unwrap().name = invalid.into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original);
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
}
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
app.paste("OnDisk");
|
|
let external = format!("{original}OnDisk = []\n");
|
|
std::fs::write(&app.config_path, &external).unwrap();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external);
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
std::fs::write(&app.config_path, &original).unwrap();
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
app.paste(" Saved union ");
|
|
// External edits do not change the union being saved.
|
|
std::fs::write(
|
|
&app.config_path,
|
|
original.replace("Alpha = ['@Alpha']", "Alpha = []"),
|
|
)
|
|
.unwrap();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.is_none());
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, ViewSource::Category("Saved union".into()));
|
|
assert_eq!(view.query, "draft physics");
|
|
assert_eq!(view.submitted_query, "physics");
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.videos.len(), 2);
|
|
assert_eq!(view.next_start, PAGE_SIZE + 1);
|
|
assert_eq!(view.feed_requests, next_requests);
|
|
assert_eq!(
|
|
view.feed_cursor.clone().plan(channels.len()).unwrap(),
|
|
next_requests
|
|
);
|
|
assert!(view.prefetch_requested);
|
|
assert!(view.has_more);
|
|
assert_eq!(view.page_error.as_deref(), Some("keep failure"));
|
|
assert!(app.job.is_none());
|
|
assert_eq!(app.config.categories["Saved union"], channels);
|
|
assert_eq!(app.config.categories["Beta"].len(), 2);
|
|
assert!(app.config.categories["Alpha"].is_empty());
|
|
assert_eq!(app.config.pinned_categories, ["Alpha"]);
|
|
assert_eq!(
|
|
app.temporary_view,
|
|
Some(ViewSource::Category("Saved union".into()))
|
|
);
|
|
let disk = std::fs::read_to_string(&app.config_path)
|
|
.unwrap()
|
|
.parse::<toml_edit::DocumentMut>()
|
|
.unwrap();
|
|
assert_eq!(
|
|
disk["categories"]["Saved union"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|value| value.as_str().unwrap())
|
|
.collect::<Vec<_>>(),
|
|
["@Beta", "@Alias"]
|
|
);
|
|
assert!(
|
|
std::fs::read_to_string(&app.config_path)
|
|
.unwrap()
|
|
.starts_with("# keep this comment")
|
|
);
|
|
rename_press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
assert_eq!(app.views[app.view_index].group_channels, channels);
|
|
let saved = std::fs::read_to_string(&app.config_path).unwrap();
|
|
std::fs::write(
|
|
&app.config_path,
|
|
saved.replace("Beta = ['@Alias', '@Beta']", "Beta = []"),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
assert!(app.views[app.view_index].group_channels.is_empty());
|
|
assert_eq!(app.views[app.view_index].query, "draft physics");
|
|
assert_eq!(app.views[app.view_index].submitted_query, "physics");
|
|
assert_eq!(app.config.categories["Saved union"], channels);
|
|
app.forward_view();
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Saved union".into())
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 2);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
|
assert_eq!(app.views[app.view_index].submitted_query, "physics");
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn configurable_group_controls_share_text_editing_and_are_inert_outside_groups() {
|
|
let keys = "[keys]\nselector.toggle = 'f5'\ncategory.save = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n";
|
|
let mut app = group_app("remapped", "");
|
|
let doc = keys.parse::<toml_edit::DocumentMut>().unwrap();
|
|
app.config.keys = keys::Bindings::from_toml(doc.get("keys")).unwrap();
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
assert_eq!(app.browser.as_ref().unwrap().checked.len(), 2);
|
|
rename_press(&mut app, KeyCode::F(5));
|
|
assert_eq!(app.browser.as_ref().unwrap().checked.len(), 1);
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SHIFT));
|
|
app.paste("P R");
|
|
assert_eq!(app.browser.as_ref().unwrap().query, " P R");
|
|
assert_eq!(app.browser.as_ref().unwrap().checked.len(), 1);
|
|
rename_press(&mut app, KeyCode::F(8));
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
app.preview = preview;
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
assert!(app.category_name_input.is_none());
|
|
rename_press(&mut app, KeyCode::F(6));
|
|
assert!(app.category_name_input.is_some());
|
|
rename_press(&mut app, KeyCode::F(9));
|
|
app.paste("日本語 words");
|
|
rename_press(&mut app, KeyCode::F(10));
|
|
rename_press(&mut app, KeyCode::F(11));
|
|
app.key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT));
|
|
app.paste("\nR\t");
|
|
assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語P R ");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.is_some());
|
|
rename_press(&mut app, KeyCode::F(8));
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original);
|
|
}
|
|
app.views[0].loaded = true;
|
|
app.switch_view(0);
|
|
rename_press(&mut app, KeyCode::F(6));
|
|
assert!(app.category_name_input.is_none());
|
|
for kind in [
|
|
BrowserKind::Channels,
|
|
BrowserKind::WatchLater,
|
|
BrowserKind::SavedVideos,
|
|
] {
|
|
app.open_browser(kind);
|
|
rename_press(&mut app, KeyCode::F(5));
|
|
rename_press(&mut app, KeyCode::F(6));
|
|
assert!(app.browser.as_ref().unwrap().checked.is_empty());
|
|
assert!(app.category_name_input.is_none());
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
}
|
|
app.back_view();
|
|
rename_press(&mut app, KeyCode::F(6));
|
|
app.paste("Remapped");
|
|
rename_press(&mut app, KeyCode::F(7));
|
|
assert!(app.category_name_input.is_none());
|
|
assert!(app.config.categories.contains_key("Remapped"));
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn combined_sources_do_not_infer_a_category_for_scoped_actions() {
|
|
let mut app = group_app("scopes", "");
|
|
let source = app.views[app.view_index].source.clone();
|
|
for key in [
|
|
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT),
|
|
KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT),
|
|
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
|
|
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
|
|
] {
|
|
app.key(key);
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
assert!(app.category_dialog.is_none());
|
|
assert!(app.category_name_input.is_none());
|
|
assert!(app.browser.is_none());
|
|
}
|
|
for key in ['W', 'V'] {
|
|
rename_press(&mut app, KeyCode::Char(key));
|
|
assert!(app.browser.is_some());
|
|
assert!(app.browser.as_ref().unwrap().checked.is_empty());
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
}
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn empty_combined_view_can_be_saved_as_an_empty_category() {
|
|
let mut app = group_app("empty-save", "");
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
std::fs::write(
|
|
&app.config_path,
|
|
original
|
|
.replace("Alpha = ['@Alpha']", "Alpha = []")
|
|
.replace("Beta = ['@Alias', '@Beta']", "Beta = []"),
|
|
)
|
|
.unwrap();
|
|
app.apply_config(config::load(&app.config_path).unwrap());
|
|
rename_press(&mut app, KeyCode::Char('P'));
|
|
app.paste("Empty union");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.is_none());
|
|
assert!(app.config.categories["Empty union"].is_empty());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Empty union".into())
|
|
);
|
|
assert!(app.views[app.view_index].loaded);
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
assert!(app.job.is_none());
|
|
assert_eq!(app.config.pinned_categories, ["Alpha"]);
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn category_checkboxes_and_dim_pins_remain_distinct_when_highlighted() {
|
|
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
|
|
let mut app = group_app("markers", "");
|
|
rename_press(&mut app, KeyCode::Char('c'));
|
|
let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let row = (0..buffer.area.height)
|
|
.find_map(|y| {
|
|
let text: String = (0..buffer.area.width)
|
|
.map(|x| buffer[(x, y)].symbol())
|
|
.collect();
|
|
text.find("[x] Alpha p")
|
|
.map(|byte| (text[..byte].chars().count() as u16, y))
|
|
})
|
|
.expect("checked and pinned category row");
|
|
assert!(buffer[(row.0 + 10, row.1)].modifier.contains(Modifier::DIM));
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let text: String = (0..buffer.area.width)
|
|
.map(|x| buffer[(x, row.1)].symbol())
|
|
.collect();
|
|
assert!(text.contains("[ ] Alpha p"));
|
|
assert!(buffer[(row.0 + 10, row.1)].modifier.contains(Modifier::DIM));
|
|
assert!(app.config.pinned_categories.contains(&"Alpha".into()));
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn category_rename_preserves_pins_cached_pages_collection_scopes_and_history() {
|
|
let mut app = rename_app("cache", "");
|
|
let videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"#
|
|
).unwrap().videos;
|
|
for source in [
|
|
ViewSource::WatchLater(Some("Alpha".into())),
|
|
ViewSource::SavedVideos(Some("Alpha".into())),
|
|
] {
|
|
let mut view = View::new("Alpha collection".into(), source);
|
|
view.videos = videos.clone();
|
|
view.selection.select(Some(1));
|
|
view.loaded = true;
|
|
view.watch_filter = WatchFilter::Watched;
|
|
app.views.push(view);
|
|
}
|
|
app.view_index = 2;
|
|
let view = &mut app.views[2];
|
|
view.query = " draft robotics ".into();
|
|
view.submitted_query = "robotics".into();
|
|
view.videos = videos.clone();
|
|
view.feed_requests = view.feed_cursor.plan(1).unwrap();
|
|
view.prefetch_requested = true;
|
|
view.selection.select(Some(1));
|
|
view.loaded = true;
|
|
view.next_start = 41;
|
|
view.has_more = true;
|
|
view.page_error = Some("keep page failure".into());
|
|
app.temporary_view = Some(ViewSource::SavedVideos(Some("Alpha".into())));
|
|
app.view_history = vec![ViewSource::WatchLater(Some("Alpha".into()))];
|
|
app.forward_history = vec![ViewSource::SavedVideos(Some("Alpha".into()))];
|
|
rename_to(&mut app, "Zeta");
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, ViewSource::Category("Zeta".into()));
|
|
assert_eq!(view.name, "Zeta");
|
|
assert_eq!(view.query, " draft robotics ");
|
|
assert_eq!(view.submitted_query, "robotics");
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.videos.len(), 2);
|
|
assert_eq!(view.feed_requests.len(), 1);
|
|
assert!(view.prefetch_requested);
|
|
assert_eq!(view.next_start, 41);
|
|
assert!(view.loaded && view.has_more);
|
|
assert_eq!(view.page_error.as_deref(), Some("keep page failure"));
|
|
assert_eq!(app.config.pinned_categories, ["Zeta"]);
|
|
assert_eq!(app.views[2].source, ViewSource::Category("Other".into()));
|
|
assert_eq!(
|
|
app.temporary_view,
|
|
Some(ViewSource::SavedVideos(Some("Zeta".into())))
|
|
);
|
|
assert_eq!(
|
|
app.view_history,
|
|
[ViewSource::WatchLater(Some("Zeta".into()))]
|
|
);
|
|
assert_eq!(
|
|
app.forward_history,
|
|
[ViewSource::SavedVideos(Some("Zeta".into()))]
|
|
);
|
|
for view in &app.views {
|
|
if matches!(
|
|
view.source,
|
|
ViewSource::WatchLater(_) | ViewSource::SavedVideos(_)
|
|
) {
|
|
assert_eq!(view.source.category(), Some("Zeta"));
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.watch_filter, WatchFilter::Watched);
|
|
assert_eq!(view.videos.len(), 2);
|
|
}
|
|
}
|
|
app.back_view();
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::WatchLater(Some("Zeta".into()))
|
|
);
|
|
app.forward_view();
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Zeta".into())
|
|
);
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rename_selectors_keep_renamed_match_selected_and_ignore_noncategory_rows() {
|
|
for (label, kind) in [
|
|
("categories", BrowserKind::Categories),
|
|
("later", BrowserKind::WatchLater),
|
|
("saved", BrowserKind::SavedVideos),
|
|
("channels", BrowserKind::Channels),
|
|
] {
|
|
let mut app = rename_app(label, "");
|
|
app.open_browser(kind);
|
|
if kind != BrowserKind::Categories {
|
|
rename_press(&mut app, KeyCode::Char('R'));
|
|
assert!(app.category_name_input.is_none());
|
|
}
|
|
if kind == BrowserKind::Channels {
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
continue;
|
|
}
|
|
let browser = app.browser.as_mut().unwrap();
|
|
browser.query = "alp".into();
|
|
browser.refilter();
|
|
rename_press(&mut app, KeyCode::Char('R'));
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "alp");
|
|
rename_to(&mut app, "Zeta");
|
|
let browser = app.browser.as_ref().unwrap();
|
|
assert!(browser.query.is_empty());
|
|
let selected = browser.matches[browser.selection.selected().unwrap()];
|
|
assert_eq!(browser.choices[selected].1.category(), Some("Zeta"));
|
|
assert!(app.config.pinned_categories.contains(&"Zeta".into()));
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
for (label, source) in [
|
|
(
|
|
"later-current",
|
|
ViewSource::WatchLater(Some("Alpha".into())),
|
|
),
|
|
(
|
|
"saved-current",
|
|
ViewSource::SavedVideos(Some("Alpha".into())),
|
|
),
|
|
] {
|
|
let mut app = rename_app(label, "");
|
|
let mut view = View::new("Alpha collection".into(), source);
|
|
view.loaded = true;
|
|
app.views.push(view);
|
|
app.view_index = app.views.len() - 1;
|
|
rename_to(&mut app, "Zeta");
|
|
assert_eq!(app.views[app.view_index].source.category(), Some("Zeta"));
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rename_validation_keeps_prompt_and_disk_unchanged_including_external_edits() {
|
|
let mut app = rename_app("invalid", "");
|
|
app.view_index = 2;
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
for name in ["", " ", "Other", "bad\nname"] {
|
|
rename_press(&mut app, KeyCode::Char('R'));
|
|
app.category_name_input.as_mut().unwrap().name = name.into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Alpha".into())
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original);
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
}
|
|
rename_to(&mut app, "Alpha");
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original);
|
|
for external in [
|
|
original.replace("Alpha = ['@Alpha']", "Alpha = []"),
|
|
original.replace("Alpha = ['@Alpha']", "Removed = ['@Alpha']"),
|
|
original.replace("Other = []", "Other = []\nZeta = []"),
|
|
"not valid toml = [".into(),
|
|
] {
|
|
std::fs::write(&app.config_path, &external).unwrap();
|
|
rename_press(&mut app, KeyCode::Char('R'));
|
|
app.category_name_input.as_mut().unwrap().name = "Zeta".into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external);
|
|
assert!(app.config.categories.contains_key("Alpha"));
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
}
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn tag_rename_preserves_staging_retargets_bulk_source_and_saves_only_on_tag_submit() {
|
|
for bulk in [false, true] {
|
|
let mut app = rename_app(if bulk { "bulk" } else { "single" }, "");
|
|
app.view_index = 2;
|
|
let channel = app.config.categories["Alpha"][0].clone();
|
|
if bulk {
|
|
app.open_bulk_category_picker();
|
|
} else {
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@Alpha".into(),
|
|
channel: channel.clone(),
|
|
},
|
|
" @Alpha ".into(),
|
|
[("Alpha".into(), true), ("Other".into(), false)].into(),
|
|
));
|
|
}
|
|
let dialog = app.category_dialog.as_mut().unwrap();
|
|
dialog.tags.insert("Staged".into(), true);
|
|
dialog.tags.insert("Unchecked".into(), false);
|
|
dialog.tags.insert("Other".into(), true);
|
|
dialog.search_names = dialog.tags.keys().map(|tag| tag.to_lowercase()).collect();
|
|
dialog.query = "Alpha".into();
|
|
dialog.refilter();
|
|
rename_to(&mut app, "Zeta");
|
|
let disk = config::load(&app.config_path).unwrap();
|
|
assert_eq!(disk.categories["Zeta"], std::slice::from_ref(&channel));
|
|
assert!(disk.categories["Other"].is_empty());
|
|
assert!(!disk.categories.contains_key("Staged"));
|
|
let dialog = app.category_dialog.as_ref().unwrap();
|
|
assert!(dialog.tags["Zeta"] && dialog.tags["Other"] && dialog.tags["Staged"]);
|
|
assert!(!dialog.tags["Unchecked"]);
|
|
if let CategoryTarget::Category { name, channels } = &dialog.target {
|
|
assert_eq!(name, "Zeta");
|
|
assert_eq!(channels, std::slice::from_ref(&channel));
|
|
}
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert!(app.category_dialog.is_none());
|
|
assert!(config::load(&app.config_path).unwrap().categories["Other"].is_empty());
|
|
// Reopen with staged memberships and rename the bulk source a second time;
|
|
// saving must use the new identity, not the now-missing original key.
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
if bulk {
|
|
CategoryTarget::Category {
|
|
name: "Zeta".into(),
|
|
channels: vec![channel.clone()],
|
|
}
|
|
} else {
|
|
CategoryTarget::Channel {
|
|
handle: "@Alpha".into(),
|
|
channel: channel.clone(),
|
|
}
|
|
},
|
|
" target ".into(),
|
|
[
|
|
("Zeta".into(), true),
|
|
("Other".into(), true),
|
|
("Staged".into(), true),
|
|
]
|
|
.into(),
|
|
));
|
|
let dialog = app.category_dialog.as_mut().unwrap();
|
|
dialog.query = "Zeta".into();
|
|
dialog.refilter();
|
|
rename_to(&mut app, "Beta");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_dialog.is_none());
|
|
let disk = config::load(&app.config_path).unwrap();
|
|
for tag in ["Beta", "Other", "Staged"] {
|
|
assert_eq!(disk.categories[tag], std::slice::from_ref(&channel));
|
|
}
|
|
assert!(!disk.categories.contains_key("Zeta"));
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_tag_rename_is_local_and_rejects_staged_and_external_collisions() {
|
|
let mut app = rename_app("new-tag", "");
|
|
let channel = app.config.categories["Alpha"][0].clone();
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@Alpha".into(),
|
|
channel,
|
|
},
|
|
" @Alpha ".into(),
|
|
[
|
|
("Alpha".into(), true),
|
|
("Other".into(), false),
|
|
("Staged".into(), false),
|
|
]
|
|
.into(),
|
|
));
|
|
let dialog = app.category_dialog.as_mut().unwrap();
|
|
dialog.query = "Staged".into();
|
|
dialog.refilter();
|
|
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
|
rename_press(&mut app, KeyCode::Char('R'));
|
|
app.category_name_input.as_mut().unwrap().name = "Other".into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
app.category_name_input.as_mut().unwrap().name = "Alpha".into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
std::fs::write(&app.config_path, format!("{original}OnDisk = []\n")).unwrap();
|
|
app.category_name_input.as_mut().unwrap().name = "OnDisk".into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
std::fs::write(&app.config_path, format!("{original}Staged = []\n")).unwrap();
|
|
app.category_name_input.as_mut().unwrap().name = "Renamed new".into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.as_ref().unwrap().error.is_some());
|
|
std::fs::write(&app.config_path, &original).unwrap();
|
|
app.category_name_input.as_mut().unwrap().name = "Renamed new".into();
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(app.category_name_input.is_none());
|
|
assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original);
|
|
let dialog = app.category_dialog.as_ref().unwrap();
|
|
assert!(!dialog.tags["Renamed new"]);
|
|
assert!(!dialog.tags.contains_key("Staged"));
|
|
rename_press(&mut app, KeyCode::Char(' '));
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(
|
|
config::load(&app.config_path)
|
|
.unwrap()
|
|
.categories
|
|
.contains_key("Renamed new")
|
|
);
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn unpinned_category_rename_retains_its_temporary_tab_and_cached_rows() {
|
|
let mut app = rename_app("unpinned", "");
|
|
let config = config::set_category_pinned(&app.config_path, "Alpha", false).unwrap();
|
|
app.apply_config(config);
|
|
app.view_index = 2;
|
|
app.views[2].loaded = true;
|
|
app.views[2].next_start = 61;
|
|
app.temporary_view = Some(ViewSource::Category("Alpha".into()));
|
|
rename_to(&mut app, "Zeta");
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Zeta".into())
|
|
);
|
|
assert_eq!(app.views[app.view_index].next_start, 61);
|
|
assert_eq!(
|
|
app.temporary_view,
|
|
Some(ViewSource::Category("Zeta".into()))
|
|
);
|
|
assert!(app.config.pinned_categories.is_empty());
|
|
assert_eq!(
|
|
app.visible_views().last().unwrap().1.source,
|
|
ViewSource::Category("Zeta".into())
|
|
);
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rename_uses_configured_actions_and_uppercase_r_stays_literal_in_all_inputs() {
|
|
let keys = "[keys]\ncategory.rename = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n";
|
|
for entry in ["view", "browser", "tags"] {
|
|
let mut app = rename_app(&format!("remapped-{entry}"), keys);
|
|
app.view_index = 2;
|
|
match entry {
|
|
"browser" => app.open_browser(BrowserKind::Categories),
|
|
"tags" => {
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@Alpha".into(),
|
|
channel: app.config.categories["Alpha"][0].clone(),
|
|
},
|
|
" target ".into(),
|
|
[("Alpha".into(), true), ("Other".into(), false)].into(),
|
|
))
|
|
}
|
|
_ => {}
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT));
|
|
assert!(
|
|
app.category_name_input.is_none(),
|
|
"default action is replaced"
|
|
);
|
|
rename_press(&mut app, KeyCode::F(6));
|
|
assert_eq!(app.key_context(), Context::CategoryNameInput);
|
|
rename_press(&mut app, KeyCode::F(9));
|
|
app.paste("日本語 words");
|
|
rename_press(&mut app, KeyCode::F(10));
|
|
assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語 ");
|
|
rename_press(&mut app, KeyCode::F(11));
|
|
app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT));
|
|
app.paste("\n?\t");
|
|
assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語R ? ");
|
|
assert!(app.help_popup.is_none());
|
|
rename_press(&mut app, KeyCode::F(8));
|
|
assert!(app.category_name_input.is_none());
|
|
assert!(app.config.categories.contains_key("Alpha"));
|
|
rename_press(&mut app, KeyCode::F(6));
|
|
rename_press(&mut app, KeyCode::F(9));
|
|
app.paste("Zeta");
|
|
rename_press(&mut app, KeyCode::Enter);
|
|
assert!(
|
|
app.category_name_input.is_some(),
|
|
"default submit is replaced"
|
|
);
|
|
rename_press(&mut app, KeyCode::F(7));
|
|
assert!(app.category_name_input.is_none());
|
|
assert!(
|
|
config::load(&app.config_path)
|
|
.unwrap()
|
|
.categories
|
|
.contains_key("Zeta")
|
|
);
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
let mut app = rename_app("literal-r", "");
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT));
|
|
assert_eq!(app.views[0].query, "R");
|
|
assert!(app.category_name_input.is_none());
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
app.open_browser(BrowserKind::Categories);
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT));
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "R");
|
|
assert!(app.category_name_input.is_none());
|
|
app.browser = None;
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@Alpha".into(),
|
|
channel: app.config.categories["Alpha"][0].clone(),
|
|
},
|
|
" target ".into(),
|
|
[("Alpha".into(), true)].into(),
|
|
));
|
|
rename_press(&mut app, KeyCode::Char('i'));
|
|
app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT));
|
|
assert_eq!(app.category_dialog.as_ref().unwrap().query, "R");
|
|
assert!(app.category_name_input.is_none());
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
rename_press(&mut app, KeyCode::Char('n'));
|
|
app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT));
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("R")
|
|
);
|
|
assert!(app.category_name_input.is_none());
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rename_prompt_cursor_scrolls_by_display_width_without_changing_selection() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
let mut app = rename_app("rename-cursor", "");
|
|
app.view_index = 2;
|
|
rename_press(&mut app, KeyCode::Char('R'));
|
|
let mut terminal = Terminal::new(TestBackend::new(24, 16)).unwrap();
|
|
for (text, x) in [
|
|
("", 3),
|
|
("日本語", 9),
|
|
("e\u{301}", 4),
|
|
("abcdefghijklmnopqrstuvwxyz", 20),
|
|
] {
|
|
app.category_name_input.as_mut().unwrap().name = text.into();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert_eq!(terminal.get_cursor_position().unwrap(), (x, 4).into());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Alpha".into())
|
|
);
|
|
}
|
|
rename_press(&mut app, KeyCode::Esc);
|
|
assert!(app.category_name_input.is_none());
|
|
std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn collection_removal_is_inert_in_ordinary_views_and_their_previews() {
|
|
let mut app = app(&["tubular"]);
|
|
app.config.categories.insert("Category".into(), vec![]);
|
|
let videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].videos = videos.clone();
|
|
app.views[0].loaded = true;
|
|
for row in 0..2 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
|
|
}
|
|
for source in [
|
|
ViewSource::Search,
|
|
ViewSource::Feed,
|
|
ViewSource::Category("Category".into()),
|
|
ViewSource::Channel(config::normalize_channel("@creator").unwrap()),
|
|
] {
|
|
let mut view = View::new("Ordinary".into(), source);
|
|
view.videos = videos.clone();
|
|
view.loaded = true;
|
|
view.selection.select(Some(0));
|
|
let index = app.views.len();
|
|
app.views.push(view);
|
|
app.switch_view(index);
|
|
for (preview, context) in [
|
|
(None, Context::Normal),
|
|
(Some(PreviewMode::Info), Context::Info),
|
|
(Some(PreviewMode::Description), Context::Description),
|
|
] {
|
|
app.preview = preview;
|
|
assert_eq!(app.key_context(), context);
|
|
assert!(app.current_collection().is_none());
|
|
let status = app.status.clone();
|
|
for key in ['x', 'X', 'D'] {
|
|
app.key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
|
}
|
|
app.remove_collection();
|
|
app.remove_collection_bulk();
|
|
assert!(app.collection_removal.is_none());
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2);
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
assert_eq!(app.views[index].selection.selected(), Some(0));
|
|
assert_eq!(app.preview, preview);
|
|
assert_eq!(app.status, status);
|
|
app.paste("xX");
|
|
assert!(app.views[0].query.is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_preview() {
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
for row in 0..2 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.save_collection(CollectionKind::Saved);
|
|
}
|
|
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
|
|
app.switch_view(0);
|
|
app.browse_collection(kind);
|
|
for (preview, context, cancel) in [
|
|
(None, Context::CollectionNormal, KeyCode::Char('n')),
|
|
(
|
|
Some(PreviewMode::Info),
|
|
Context::CollectionInfo,
|
|
KeyCode::Esc,
|
|
),
|
|
(
|
|
Some(PreviewMode::Description),
|
|
Context::CollectionDescription,
|
|
KeyCode::Char('n'),
|
|
),
|
|
] {
|
|
app.preview = preview;
|
|
assert_eq!(app.key_context(), context);
|
|
app.key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT));
|
|
assert!(app.collection_removal.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
assert_eq!(app.preview, None);
|
|
let removal = app.collection_removal.as_ref().unwrap();
|
|
assert_eq!(removal.ids.len(), 2);
|
|
assert!(removal.category.is_none());
|
|
assert_eq!(
|
|
removal.filter,
|
|
matches!(kind, CollectionKind::WatchLater).then_some(WatchFilter::All)
|
|
);
|
|
let history = app.view_history.clone();
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
app.paste("y");
|
|
assert!(app.collection_removal.is_some());
|
|
assert!(app.player_launch.is_none());
|
|
assert_eq!(app.view_history, history);
|
|
assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
|
|
assert!(app.collection_removal.is_some());
|
|
app.key(KeyEvent::new(cancel, KeyModifiers::NONE));
|
|
assert!(app.collection_removal.is_none());
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2);
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::SearchInput);
|
|
app.paste("xX");
|
|
assert_eq!(app.views[0].query, "xX");
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::CollectionNormal);
|
|
app.views[0].query.clear();
|
|
app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::Help);
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
assert!(app.collection_removal.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::CollectionNormal);
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::SelectorNormal);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::SelectorInput);
|
|
app.paste("xX");
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::CollectionNormal);
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@creator".into(),
|
|
channel: config::normalize_channel("@creator").unwrap(),
|
|
},
|
|
"Creator".into(),
|
|
[("Category".into(), false)].into(),
|
|
));
|
|
assert_eq!(app.key_context(), Context::TagList);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::TagFilter);
|
|
app.paste("xX");
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::TagInput);
|
|
app.paste("xX");
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("xX")
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.key_context(), Context::CollectionNormal);
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2);
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
}
|
|
}
|
|
|
|
#[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()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(
|
|
root.join("watch-state.toml"),
|
|
"version = 1\nwatched = ['jNQXAC9IVRw']\n",
|
|
)
|
|
.unwrap();
|
|
let category_channels = vec![config::normalize_channel("@creator").unwrap()];
|
|
let config = config::Config {
|
|
categories: [("Category".into(), category_channels.clone())].into(),
|
|
..Default::default()
|
|
};
|
|
let path = root.join("config.toml");
|
|
let mut app = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config,
|
|
video_state::State::open(&path).unwrap(),
|
|
);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Watched","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Unwatched","uploader_id":"@creator"},{"id":"l-GaaivVJCE","title":"Outside category","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
for row in 0..3 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.save_collection(CollectionKind::Saved);
|
|
}
|
|
app.views[2].loaded = true;
|
|
app.switch_view(2);
|
|
app.browse_category_collection(CollectionKind::Saved);
|
|
app.preview = Some(PreviewMode::Description);
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
|
|
let removal = app.collection_removal.as_ref().unwrap();
|
|
assert!(matches!(removal.kind, CollectionKind::Saved));
|
|
assert_eq!(removal.category.as_deref(), Some("Category"));
|
|
assert_eq!(removal.filter, None);
|
|
assert_eq!(removal.ids, ["jNQXAC9IVRw", "rQ_J9WH6CGk"]);
|
|
let prompt = removal.text.clone();
|
|
app.apply_config(config::Config {
|
|
categories: [(
|
|
"Category".into(),
|
|
vec![
|
|
config::normalize_channel("@creator").unwrap(),
|
|
config::normalize_channel("@other").unwrap(),
|
|
],
|
|
)]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(app.views[app.view_index].videos.len(), 3);
|
|
assert_eq!(app.collection_removal.as_ref().unwrap().text, prompt);
|
|
app.browse_category_collection(CollectionKind::WatchLater);
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::Saved, "l-GaaivVJCE")
|
|
);
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3);
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
app.apply_config(config::Config {
|
|
categories: [("Category".into(), category_channels)].into(),
|
|
..Default::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::Watched);
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
let removal = app.collection_removal.as_ref().unwrap();
|
|
assert!(matches!(removal.kind, CollectionKind::WatchLater));
|
|
assert_eq!(removal.filter, Some(WatchFilter::Watched));
|
|
assert_eq!(removal.ids, ["jNQXAC9IVRw"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2);
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1);
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
app.switch_view(0);
|
|
app.browse_collection(CollectionKind::WatchLater);
|
|
app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].watch_filter,
|
|
WatchFilter::Unwatched
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 2);
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
assert!(app.collection_removal.as_ref().unwrap().category.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert!(
|
|
app.video_state
|
|
.videos(CollectionKind::WatchLater)
|
|
.is_empty()
|
|
);
|
|
app.browse_collection(CollectionKind::Saved);
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert!(app.video_state.videos(CollectionKind::Saved).is_empty());
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
drop(app);
|
|
let restarted = video_state::State::open(&path).unwrap();
|
|
assert!(restarted.videos(CollectionKind::Saved).is_empty());
|
|
assert!(restarted.videos(CollectionKind::WatchLater).is_empty());
|
|
assert!(restarted.is_watched("jNQXAC9IVRw"));
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn individual_watch_later_removal_keeps_siblings_saved_and_watched() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"tubular-single-watch-removal-{}",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(
|
|
root.join("watch-state.toml"),
|
|
"version = 1\nwatched = ['jNQXAC9IVRw']\n",
|
|
)
|
|
.unwrap();
|
|
let mut app = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
root.join("config.toml"),
|
|
config::Config::default(),
|
|
video_state::State::open(&root.join("config.toml")).unwrap(),
|
|
);
|
|
app.views[0].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].loaded = true;
|
|
app.views[0].selection.select(Some(0));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.save_collection(CollectionKind::Saved);
|
|
app.views[0].selection.select(Some(1));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.browse_collection(CollectionKind::WatchLater);
|
|
app.views[app.view_index].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
assert!(
|
|
!app.video_state
|
|
.contains(CollectionKind::WatchLater, "jNQXAC9IVRw")
|
|
);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::WatchLater, "rQ_J9WH6CGk")
|
|
);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::Saved, "jNQXAC9IVRw")
|
|
);
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
assert!(app.collection_removal.is_none());
|
|
app.views[app.view_index].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Info);
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
assert!(
|
|
app.video_state
|
|
.videos(CollectionKind::WatchLater)
|
|
.is_empty()
|
|
);
|
|
assert_eq!(app.preview, None);
|
|
app.switch_view(0);
|
|
app.views[0].selection.select(Some(1));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.browse_collection(CollectionKind::WatchLater);
|
|
app.preview = Some(PreviewMode::Description);
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
assert!(
|
|
app.video_state
|
|
.videos(CollectionKind::WatchLater)
|
|
.is_empty()
|
|
);
|
|
assert_eq!(app.preview, None);
|
|
drop(app);
|
|
let restarted = video_state::State::open(&root.join("config.toml")).unwrap();
|
|
assert!(restarted.videos(CollectionKind::WatchLater).is_empty());
|
|
assert!(restarted.contains(CollectionKind::Saved, "jNQXAC9IVRw"));
|
|
assert!(restarted.is_watched("jNQXAC9IVRw"));
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn manual_status_updates_filtered_lists_and_closes_removed_previews() {
|
|
let mut app = app(&["tubular"]);
|
|
let document = "[keys]\nvideo.status.watching = 'f6'\nvideo.status.watched = 'f7'\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"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
for row in 0..2 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
}
|
|
app.browse_collection(CollectionKind::WatchLater);
|
|
app.filter_watch_later();
|
|
app.views[app.view_index].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Info);
|
|
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk");
|
|
assert_eq!(app.preview, None);
|
|
app.filter_watch_later();
|
|
assert_eq!(app.views[app.view_index].videos[0].id, "jNQXAC9IVRw");
|
|
app.preview = Some(PreviewMode::Description);
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
assert!(!app.video_state.is_watched("jNQXAC9IVRw"));
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
assert_eq!(app.views[app.view_index].selection.selected(), None);
|
|
assert_eq!(app.preview, None);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::Watching, "jNQXAC9IVRw")
|
|
);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::WatchLater, "jNQXAC9IVRw")
|
|
);
|
|
app.browse_collection(CollectionKind::Watching);
|
|
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
}
|
|
|
|
#[test]
|
|
fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"tubular-watch-ui-regression-{}",
|
|
std::process::id()
|
|
));
|
|
let path = root.join("config.toml");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
let state = video_state::State::open(&path).unwrap();
|
|
let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap();
|
|
let config = config::Config {
|
|
channels: [("@creator".into(), channel.clone())].into(),
|
|
categories: [("Category".into(), vec![channel.clone()])].into(),
|
|
..Default::default()
|
|
};
|
|
let mut app = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config,
|
|
state,
|
|
);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(0));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.views[0].selection.select(Some(1));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2);
|
|
app.views[0].selection.select(None);
|
|
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let title = buffer
|
|
.content
|
|
.iter()
|
|
.position(|cell| cell.symbol() == "M" && cell.fg == Color::Magenta);
|
|
assert!(title.is_some(), "saved video titles use a distinct color");
|
|
app.views[0].selection.select(Some(1));
|
|
|
|
// An empty loaded category avoids extraction while opening its derived offline list.
|
|
app.views[2].loaded = true;
|
|
app.switch_view(2);
|
|
app.browse_category_collection(CollectionKind::WatchLater);
|
|
let category_source = ViewSource::WatchLater(Some("Category".into()));
|
|
assert_eq!(app.views[app.view_index].source, category_source);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
app.browse_category_collection(CollectionKind::WatchLater);
|
|
assert_eq!(app.views[app.view_index].source, category_source);
|
|
assert!(app.job.is_none());
|
|
|
|
app.filter_watch_later();
|
|
assert_eq!(
|
|
app.views[app.view_index].watch_filter,
|
|
WatchFilter::Unwatched
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
app.filter_watch_later();
|
|
assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::Watched);
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
assert!(app.collection_removal.is_none());
|
|
app.filter_watch_later();
|
|
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
let history = app.view_history.clone();
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
app.paste("y");
|
|
assert!(app.collection_removal.is_some());
|
|
assert!(app.player_launch.is_none());
|
|
assert_eq!(app.view_history, history);
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
assert!(app.collection_removal.is_none());
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2);
|
|
|
|
app.apply_config(config::Config {
|
|
channels: [("@creator".into(), channel.clone())].into(),
|
|
categories: [("Category".into(), vec![])].into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(app.views[app.view_index].source, category_source);
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
app.apply_config(config::Config {
|
|
channels: [("@creator".into(), channel.clone())].into(),
|
|
categories: [("Category".into(), vec![channel])].into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(
|
|
app.views[app.view_index].videos.len(),
|
|
1,
|
|
"mapped handles follow canonical membership"
|
|
);
|
|
app.apply_config(config::Config {
|
|
categories: [(
|
|
"Category".into(),
|
|
vec![config::normalize_channel("@creator").unwrap()],
|
|
)]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(
|
|
app.views[app.view_index].videos.len(),
|
|
1,
|
|
"unmapped handles remain usable"
|
|
);
|
|
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
app.apply_config(config::Config {
|
|
categories: [(
|
|
"Category".into(),
|
|
vec![
|
|
config::normalize_channel("@creator").unwrap(),
|
|
config::normalize_channel("@other").unwrap(),
|
|
],
|
|
)]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(app.views[app.view_index].videos.len(), 2);
|
|
assert_eq!(
|
|
app.collection_removal.as_ref().unwrap().ids,
|
|
["jNQXAC9IVRw"]
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 1);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::WatchLater, "rQ_J9WH6CGk")
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
|
|
app.switch_view(0);
|
|
app.browse_collection(CollectionKind::WatchLater);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::WatchLater(None)
|
|
);
|
|
app.back_view();
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
app.forward_view();
|
|
drop(app);
|
|
let mut restarted = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config::Config::default(),
|
|
video_state::State::open(&path).unwrap(),
|
|
);
|
|
restarted.browse_collection(CollectionKind::WatchLater);
|
|
assert!(restarted.job.is_none());
|
|
assert!(restarted.views[restarted.view_index].loaded);
|
|
assert_eq!(
|
|
restarted.views[restarted.view_index].videos[0].title,
|
|
"Other creator"
|
|
);
|
|
restarted.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
restarted.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert!(
|
|
restarted
|
|
.video_state
|
|
.videos(CollectionKind::WatchLater)
|
|
.is_empty()
|
|
);
|
|
drop(restarted);
|
|
assert!(
|
|
video_state::State::open(&path)
|
|
.unwrap()
|
|
.videos(CollectionKind::WatchLater)
|
|
.is_empty()
|
|
);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn saved_views_follow_current_categories_and_restore_offline_history() {
|
|
let root = std::env::temp_dir().join(format!("tubular-saved-ui-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let path = root.join("config.toml");
|
|
std::fs::write(
|
|
root.join("watch-state.toml"),
|
|
"version = 1\nwatched = [\"jNQXAC9IVRw\"]\n",
|
|
)
|
|
.unwrap();
|
|
let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap();
|
|
let other = config::normalize_channel("@other").unwrap();
|
|
let config = config::Config {
|
|
channels: [("@creator".into(), channel.clone())].into(),
|
|
categories: [
|
|
("Category".into(), vec![channel.clone(), other.clone()]),
|
|
("Overlap".into(), vec![channel]),
|
|
]
|
|
.into(),
|
|
..Default::default()
|
|
};
|
|
let mut app = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config,
|
|
video_state::State::open(&path).unwrap(),
|
|
);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
for row in 0..2 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
|
}
|
|
app.views[2].loaded = true;
|
|
app.switch_view(2);
|
|
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
|
|
let saved_category = ViewSource::SavedVideos(Some("Category".into()));
|
|
assert_eq!(app.views[app.view_index].source, saved_category);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 2);
|
|
app.views[app.view_index].selection.select(Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::WatchLater(Some("Category".into()))
|
|
);
|
|
app.filter_watch_later();
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
assert!(app.views[app.view_index].title.contains("Unwatched"));
|
|
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
|
|
assert_eq!(app.views[app.view_index].source, saved_category);
|
|
assert_eq!(
|
|
app.views[app.view_index].videos.len(),
|
|
2,
|
|
"saved lists ignore watch filters"
|
|
);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
|
let mut config = config::Config {
|
|
channels: app.config.channels.clone(),
|
|
categories: app.config.categories.clone(),
|
|
..Default::default()
|
|
};
|
|
config.categories.insert("Category".into(), vec![other]);
|
|
app.apply_config(config);
|
|
assert_eq!(app.views[app.view_index].source, saved_category);
|
|
assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk");
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(0));
|
|
let watch = app
|
|
.views
|
|
.iter()
|
|
.find(|view| matches!(view.source, ViewSource::WatchLater(Some(_))))
|
|
.unwrap();
|
|
assert_eq!(watch.watch_filter, WatchFilter::Unwatched);
|
|
assert!(watch.title.contains("Unwatched"));
|
|
app.switch_view(0);
|
|
app.key(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::SavedVideos(None)
|
|
);
|
|
assert_eq!(
|
|
app.views[app.view_index].videos.len(),
|
|
2,
|
|
"overlapping categories do not duplicate videos"
|
|
);
|
|
app.views[app.view_index].selection.select(Some(1));
|
|
app.back_view();
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
app.back_view();
|
|
assert_eq!(app.views[app.view_index].source, saved_category);
|
|
app.forward_view();
|
|
app.forward_view();
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::SavedVideos(None)
|
|
);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
|
assert!(app.job.is_none());
|
|
app.views[app.view_index].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Info);
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
assert!(
|
|
app.preview.is_none(),
|
|
"removing the selected row dismisses its preview"
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::WatchLater, "jNQXAC9IVRw")
|
|
);
|
|
assert!(app.video_state.is_watched("jNQXAC9IVRw"));
|
|
let config = std::mem::take(&mut app.config);
|
|
drop(app);
|
|
let mut restarted = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config,
|
|
video_state::State::open(&path).unwrap(),
|
|
);
|
|
restarted.browse_collection(CollectionKind::Saved);
|
|
let view = &restarted.views[restarted.view_index];
|
|
assert!(view.loaded && !view.has_more);
|
|
assert_eq!(view.videos.len(), 1);
|
|
assert_eq!(view.videos[0].title, "Other creator");
|
|
assert!(restarted.job.is_none());
|
|
assert!(restarted.video_state.is_watched("jNQXAC9IVRw"));
|
|
assert_eq!(
|
|
restarted
|
|
.video_state
|
|
.videos(CollectionKind::WatchLater)
|
|
.len(),
|
|
2
|
|
);
|
|
drop(restarted);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn collection_shortcuts_separate_global_and_category_scopes_in_every_video_context() {
|
|
for custom in [false, true] {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"tubular-collection-scopes-{}-{custom}",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(
|
|
root.join("watch-state.toml"),
|
|
"version = 1\nwatched = ['l-GaaivVJCE']\n",
|
|
)
|
|
.unwrap();
|
|
let path = root.join("config.toml");
|
|
let mut app = App::with_state(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config::Config::default(),
|
|
video_state::State::open(&path).unwrap(),
|
|
);
|
|
let bindings = if custom {
|
|
let document = "[keys]\nview.watch_later.browse = 'f5'\nview.saved.browse = 'f6'\nview.watch_later.category = 'f7'\nview.saved.category = 'f8'\n"
|
|
.parse::<toml_edit::DocumentMut>().unwrap();
|
|
keys::Bindings::from_toml(document.get("keys")).unwrap()
|
|
} else {
|
|
keys::Bindings::default()
|
|
};
|
|
app.apply_config(config::Config {
|
|
categories: [(
|
|
"Category".into(),
|
|
vec![config::normalize_channel("@creator").unwrap()],
|
|
)]
|
|
.into(),
|
|
keys: bindings,
|
|
..Default::default()
|
|
});
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Category","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Watched category","uploader_id":"@creator"}]}"#,
|
|
).unwrap().videos;
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
}
|
|
for row in 0..3 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
app.save_collection(CollectionKind::Saved);
|
|
}
|
|
let key = |function, character, modifiers| {
|
|
KeyEvent::new(
|
|
if custom {
|
|
KeyCode::F(function)
|
|
} else {
|
|
KeyCode::Char(character)
|
|
},
|
|
if custom {
|
|
KeyModifiers::NONE
|
|
} else {
|
|
modifiers
|
|
},
|
|
)
|
|
};
|
|
let global_watch = key(5, 'W', KeyModifiers::SHIFT);
|
|
let global_saved = key(6, 'V', KeyModifiers::SHIFT);
|
|
let category_watch = key(7, 'w', KeyModifiers::ALT);
|
|
let category_saved = key(8, 'v', KeyModifiers::ALT);
|
|
for (selector, expected) in [
|
|
(
|
|
global_watch,
|
|
ViewSource::WatchLater(Some("Category".into())),
|
|
),
|
|
(
|
|
global_saved,
|
|
ViewSource::SavedVideos(Some("Category".into())),
|
|
),
|
|
] {
|
|
app.switch_view(0);
|
|
let history = app.view_history.clone();
|
|
app.key(selector);
|
|
let browser = app.browser.as_ref().unwrap();
|
|
assert!(matches!(
|
|
browser.choices[0].1,
|
|
ViewSource::WatchLater(None) | ViewSource::SavedVideos(None)
|
|
));
|
|
app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
|
assert!(app.config.pinned_categories.is_empty());
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
for c in "zzzz".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.browser.as_ref().unwrap().matches.is_empty());
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
assert_eq!(app.view_history, history);
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
|
|
for c in "category".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
assert_eq!(app.browser.as_ref().unwrap().matches.len(), 1);
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].source, expected);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 2);
|
|
assert!(app.browser.is_none());
|
|
}
|
|
// Seed a cached combined view with a different filter.
|
|
choose_combined(&mut app, global_watch);
|
|
assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::All);
|
|
app.switch_view(2);
|
|
app.key(category_watch);
|
|
app.filter_watch_later();
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
app.preview = preview;
|
|
choose_combined(&mut app, global_watch);
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, ViewSource::WatchLater(None));
|
|
assert_eq!(view.watch_filter, WatchFilter::Unwatched);
|
|
assert_eq!(
|
|
view.videos
|
|
.iter()
|
|
.map(|video| video.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["jNQXAC9IVRw", "rQ_J9WH6CGk"]
|
|
);
|
|
assert_eq!(app.preview, None);
|
|
let history = app.view_history.clone();
|
|
app.preview = preview;
|
|
choose_combined(&mut app, global_watch);
|
|
assert_eq!(app.preview, None);
|
|
assert_eq!(app.view_history, history);
|
|
app.back_view();
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::WatchLater(Some("Category".into()))
|
|
);
|
|
assert_eq!(
|
|
app.views[app.view_index].watch_filter,
|
|
WatchFilter::Unwatched
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 1);
|
|
}
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
app.preview = preview;
|
|
app.key(category_saved);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::SavedVideos(Some("Category".into()))
|
|
);
|
|
assert_eq!(app.preview, None);
|
|
app.preview = preview;
|
|
choose_combined(&mut app, global_saved);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::SavedVideos(None)
|
|
);
|
|
assert_eq!(app.views[app.view_index].videos.len(), 3);
|
|
assert_eq!(app.preview, None);
|
|
app.back_view();
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::SavedVideos(Some("Category".into()))
|
|
);
|
|
app.preview = preview;
|
|
app.key(category_watch);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::WatchLater(Some("Category".into()))
|
|
);
|
|
assert_eq!(
|
|
app.views[app.view_index].watch_filter,
|
|
WatchFilter::Unwatched
|
|
);
|
|
assert_eq!(app.preview, None);
|
|
}
|
|
app.switch_view(0);
|
|
choose_combined(&mut app, global_watch);
|
|
assert_eq!(
|
|
app.views[app.view_index].watch_filter,
|
|
WatchFilter::Unwatched
|
|
);
|
|
let mut channel = View::new(
|
|
"Creator".into(),
|
|
ViewSource::Channel(config::normalize_channel("@creator").unwrap()),
|
|
);
|
|
channel.loaded = true;
|
|
app.views.push(channel);
|
|
for index in [0, 1, app.views.len() - 1] {
|
|
app.switch_view(index);
|
|
for key in [category_watch, category_saved] {
|
|
let history = app.view_history.clone();
|
|
app.key(key);
|
|
assert_eq!(app.view_index, index);
|
|
assert_eq!(app.view_history, history);
|
|
}
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
for key in [global_watch, global_saved] {
|
|
app.preview = preview;
|
|
choose_combined(&mut app, key);
|
|
assert!(matches!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::WatchLater(None) | ViewSource::SavedVideos(None)
|
|
));
|
|
assert_eq!(app.preview, None);
|
|
app.back_view();
|
|
assert_eq!(app.view_index, index);
|
|
}
|
|
}
|
|
}
|
|
for key in [global_watch, global_saved] {
|
|
choose_combined(&mut app, key);
|
|
let source = app.views[app.view_index].source.clone();
|
|
for category_key in [category_watch, category_saved] {
|
|
app.key(category_key);
|
|
assert_eq!(app.views[app.view_index].source, source);
|
|
}
|
|
}
|
|
assert!(app.job.is_none());
|
|
drop(app);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn collection_shortcuts_leave_text_entry_bindings_unchanged() {
|
|
let mut app = app(&["tubular"]);
|
|
let type_keys = |app: &mut App| {
|
|
for character in ['W', 'V', 'w', 'v'] {
|
|
app.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
|
|
}
|
|
for character in ['w', 'v'] {
|
|
app.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT));
|
|
}
|
|
assert_eq!(app.view_index, 0);
|
|
assert!(app.view_history.is_empty());
|
|
};
|
|
app.mode = Mode::Insert;
|
|
type_keys(&mut app);
|
|
assert_eq!(app.views[0].query, "WVwv");
|
|
app.mode = Mode::Normal;
|
|
for kind in [BrowserKind::Categories, BrowserKind::Channels] {
|
|
app.open_browser(kind);
|
|
app.browser.as_mut().unwrap().mode = Mode::Insert;
|
|
type_keys(&mut app);
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "WVwv");
|
|
app.browser = None;
|
|
}
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@creator".into(),
|
|
channel: "creator".into(),
|
|
},
|
|
"Creator".into(),
|
|
BTreeMap::new(),
|
|
));
|
|
app.category_dialog.as_mut().unwrap().mode = Mode::Insert;
|
|
type_keys(&mut app);
|
|
assert_eq!(app.category_dialog.as_ref().unwrap().query, "WVwv");
|
|
app.category_dialog.as_mut().unwrap().name = Some(String::new());
|
|
type_keys(&mut app);
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("WVwv")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_actions_use_configured_keys_in_each_video_context_and_remove_only_selection() {
|
|
let mut app = app(&["tubular"]);
|
|
let document = "[keys]\nvideo.saved.save = 'f6'\nview.saved.browse = 'f7'\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"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Missing creator"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(1));
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
app.views[0].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
app.preview = preview;
|
|
if !app
|
|
.video_state
|
|
.contains(CollectionKind::Saved, "jNQXAC9IVRw")
|
|
{
|
|
app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
|
|
assert!(
|
|
!app.video_state
|
|
.contains(CollectionKind::Saved, "jNQXAC9IVRw")
|
|
);
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
let status = app.status.clone();
|
|
app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
assert_eq!(app.preview, preview);
|
|
assert_eq!(app.status, status);
|
|
}
|
|
for preview in [
|
|
None,
|
|
Some(PreviewMode::Info),
|
|
Some(PreviewMode::Description),
|
|
] {
|
|
app.preview = None;
|
|
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let row = app.views[app.view_index]
|
|
.videos
|
|
.iter()
|
|
.position(|video| video.id == "jNQXAC9IVRw")
|
|
.unwrap();
|
|
app.views[app.view_index].selection.select(Some(row));
|
|
app.preview = preview;
|
|
assert!(matches!(
|
|
app.current_collection(),
|
|
Some(CollectionKind::Saved)
|
|
));
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::Saved, "rQ_J9WH6CGk")
|
|
);
|
|
assert!(
|
|
app.video_state
|
|
.contains(CollectionKind::WatchLater, "jNQXAC9IVRw")
|
|
);
|
|
assert_eq!(app.preview, None);
|
|
app.switch_view(0);
|
|
app.views[0].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
}
|
|
app.preview = None;
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
app.views[0].selection.select(Some(2));
|
|
app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
|
|
assert!(app.status.contains("channel identity"));
|
|
assert!(
|
|
!app.video_state
|
|
.contains(CollectionKind::Saved, "l-GaaivVJCE")
|
|
);
|
|
app.views[0].selection.select(None);
|
|
let status = app.status.clone();
|
|
app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
|
|
assert_eq!(app.status, status);
|
|
app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::SavedVideos(None)
|
|
);
|
|
assert!(app.job.is_none());
|
|
assert!(app.key_hints.footer(Context::Normal).contains("F6/F7"));
|
|
assert!(app.key_hints.saved_videos.contains("F8"));
|
|
app.key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT));
|
|
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT));
|
|
assert!(app.collection_removal.is_none());
|
|
app.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE));
|
|
assert!(app.collection_removal.is_some());
|
|
app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
assert!(
|
|
app.video_state
|
|
.videos(CollectionKind::WatchLater)
|
|
.is_empty()
|
|
);
|
|
assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2);
|
|
assert!(!app.video_state.is_watched("jNQXAC9IVRw"));
|
|
}
|
|
|
|
#[test]
|
|
fn saved_green_precedes_watch_later_magenta_without_changing_creator_or_selection_colors() {
|
|
let mut app = app(&["tubular"]);
|
|
app.config.categories.insert(
|
|
"Tagged".into(),
|
|
vec![config::normalize_channel("@creator").unwrap()],
|
|
);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Saved title","channel":"Creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Later title","channel":"Other","uploader_id":"@other"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
for row in 0..2 {
|
|
app.views[0].selection.select(Some(row));
|
|
app.save_collection(CollectionKind::WatchLater);
|
|
}
|
|
app.views[0].selection.select(Some(0));
|
|
app.save_collection(CollectionKind::Saved);
|
|
app.views[0].selection.select(None);
|
|
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
assert!(
|
|
buffer
|
|
.content
|
|
.iter()
|
|
.any(|cell| cell.symbol() == "S" && cell.fg == Color::Green)
|
|
);
|
|
assert!(
|
|
buffer
|
|
.content
|
|
.iter()
|
|
.any(|cell| cell.symbol() == "L" && cell.fg == Color::Magenta)
|
|
);
|
|
assert!(
|
|
buffer
|
|
.content
|
|
.iter()
|
|
.any(|cell| cell.symbol() == "C" && cell.fg == Color::Cyan)
|
|
);
|
|
app.views[0].selection.select(Some(0));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert!(
|
|
terminal
|
|
.backend()
|
|
.buffer()
|
|
.content
|
|
.iter()
|
|
.any(|cell| cell.symbol() == "S" && cell.fg == Color::Black && cell.bg == Color::Cyan)
|
|
);
|
|
app.browse_collection(CollectionKind::Saved);
|
|
app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
app.switch_view(0);
|
|
app.views[0].selection.select(None);
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert!(
|
|
terminal
|
|
.backend()
|
|
.buffer()
|
|
.content
|
|
.iter()
|
|
.any(|cell| cell.symbol() == "S" && cell.fg == Color::Magenta)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn paste_targets_only_active_inputs_and_refilters_without_executing_shortcuts() {
|
|
let mut app = app(&["tubular"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.paste("Haru 日本語\r\ns\t\x1b[31m");
|
|
assert_eq!(app.views[0].query, "Haru 日本語 s [31m");
|
|
assert_eq!(app.mode, Mode::Insert);
|
|
assert!(app.views[0].submitted_query.is_empty());
|
|
assert!(app.job.is_none(), "paste must not submit a search");
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.paste("i\nq");
|
|
assert_eq!(app.views[0].query, "Haru 日本語 s [31m");
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
|
|
app.config.categories = [("Alpha".into(), vec![]), ("日本語".into(), vec![])].into();
|
|
app.config.channels = [
|
|
(
|
|
"@Alpha".into(),
|
|
config::normalize_channel("@Alpha").unwrap(),
|
|
),
|
|
(
|
|
"@日本語".into(),
|
|
config::normalize_channel("@日本語").unwrap(),
|
|
),
|
|
]
|
|
.into();
|
|
for kind in [BrowserKind::Categories, BrowserKind::Channels] {
|
|
app.open_browser(kind);
|
|
app.paste("Alpha");
|
|
assert!(app.browser.as_ref().unwrap().query.is_empty());
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.paste("日本語");
|
|
let browser = app.browser.as_ref().unwrap();
|
|
assert_eq!(browser.matches, [1]);
|
|
assert_eq!(browser.selection.selected(), Some(0));
|
|
assert!(browser.choices[browser.matches[0]].0.contains("日本語"));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
}
|
|
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@a".into(),
|
|
channel: String::new(),
|
|
},
|
|
"Creator".into(),
|
|
[("Alpha".into(), true), ("日本語".into(), false)].into(),
|
|
));
|
|
app.paste("i\n ");
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["Alpha"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.paste("日本語");
|
|
assert_eq!(app.category_dialog.as_ref().unwrap().matches, [1]);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["日本語"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
app.paste("New\nTag\t?");
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("New Tag ?")
|
|
);
|
|
assert!(
|
|
app.help_popup.is_none(),
|
|
"pasted ? must remain literal text"
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["New Tag ?"]);
|
|
assert!(
|
|
!app.config.categories.contains_key("New Tag ?"),
|
|
"paste must not save tags"
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
|
app.paste("n\nq");
|
|
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
|
|
assert!(
|
|
app.help_popup.is_some(),
|
|
"paste must not close help or operate on its dialog"
|
|
);
|
|
}
|
|
|
|
fn loaded_channel_views(app: &mut App) {
|
|
let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
|
|
for (label, identity) in app.config.saved_channels() {
|
|
let mut view = View::new(label, ViewSource::Channel(identity));
|
|
view.videos = feed_fixture(videos).unwrap().videos;
|
|
view.loaded = true;
|
|
view.next_start = 41;
|
|
view.selection.select(Some(1));
|
|
app.views.push(view);
|
|
}
|
|
}
|
|
|
|
fn browse_channel(app: &mut App, filter: &str) {
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
for c in filter.chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
}
|
|
|
|
#[test]
|
|
fn video_channel_colors_follow_category_membership_and_preserve_selection_contrast() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
let tagged_id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let registered_id = "UCaYhcUwRBNscFNUKTjgPFiA";
|
|
app.config.channels.insert(
|
|
"@mapped".into(),
|
|
config::normalize_channel(tagged_id).unwrap(),
|
|
);
|
|
app.config.channels.insert(
|
|
"@registered".into(),
|
|
config::normalize_channel(registered_id).unwrap(),
|
|
);
|
|
app.config.categories.insert(
|
|
"Favorites".into(),
|
|
vec![
|
|
config::normalize_channel(tagged_id).unwrap(),
|
|
config::normalize_channel("@direct").unwrap(),
|
|
],
|
|
);
|
|
let cases = [
|
|
("TaggedID", Some(tagged_id), None, true),
|
|
("TaggedHandle", None, Some("@direct"), true),
|
|
("MappedHandle", None, Some("@mapped"), true),
|
|
(
|
|
"Registered",
|
|
Some(registered_id),
|
|
Some("@registered"),
|
|
false,
|
|
),
|
|
("TaggedID", Some(registered_id), None, false),
|
|
("Unknown", None, None, false),
|
|
];
|
|
app.views[0].videos = youtube::search_results(
|
|
&serde_json::json!({
|
|
"entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({
|
|
"id": "jNQXAC9IVRw", "title": "Video title", "channel": channel,
|
|
"channel_id": id, "uploader_id": handle, "upload_date": "20261011",
|
|
"duration": 3723.5
|
|
})).collect::<Vec<_>>()
|
|
})
|
|
.to_string(),
|
|
)
|
|
.unwrap()
|
|
.videos;
|
|
app.views[0].selection.select(None);
|
|
let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
|
|
for cleared in [false, true] {
|
|
if cleared {
|
|
app.config.categories.clear();
|
|
}
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
// One tab row, three search rows, then the results border and two-line entries.
|
|
for (index, (label, _, _, tagged)) in cases.iter().enumerate() {
|
|
let y = 6 + 2 * index as u16;
|
|
let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect();
|
|
let x = row.find(label).expect("channel label is visible") as u16;
|
|
assert_eq!(
|
|
buffer[(x, y)].fg,
|
|
if *tagged && !cleared {
|
|
Color::Cyan
|
|
} else {
|
|
Color::Reset
|
|
},
|
|
"{label} in row {index}, cleared={cleared}"
|
|
);
|
|
let title: String = (0..120).map(|x| buffer[(x, y - 1)].symbol()).collect();
|
|
assert!(title.contains("Video title"));
|
|
let date_x = row.find("2026-10-11").expect("date follows channel") as u16;
|
|
assert!(row.contains(&format!("{label} · 2026-10-11")));
|
|
assert!(
|
|
row.contains(" · 1:02:03"),
|
|
"duration is visible in metadata"
|
|
);
|
|
assert_eq!(buffer[(3, y - 1)].fg, Color::Reset, "title stays uncolored");
|
|
assert_eq!(buffer[(date_x, y)].fg, Color::Reset, "date stays uncolored");
|
|
assert!(
|
|
buffer[(x, y)]
|
|
.modifier
|
|
.contains(ratatui::style::Modifier::DIM)
|
|
);
|
|
}
|
|
if !cleared {
|
|
app.views[0].selection.select(Some(0));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let row: String = (0..120).map(|x| buffer[(x, 6)].symbol()).collect();
|
|
let x = row.find("TaggedID").unwrap() as u16;
|
|
assert_eq!(buffer[(x, 6)].fg, Color::Black);
|
|
assert_eq!(buffer[(x, 6)].bg, Color::Cyan);
|
|
app.views[0].selection.select(None);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn remapped_preview_and_input_actions_survive_key_only_config_reload() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
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;
|
|
// Invalid IDs avoid curl; full descriptions exercise the real preview without extraction.
|
|
for (index, id) in ["first", "second"].into_iter().enumerate() {
|
|
app.views[0].videos[index].id = id.into();
|
|
app.remember_description(id.into(), "A long description paragraph.\n\n".repeat(40));
|
|
}
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(0));
|
|
app.views[0].next_start = 41;
|
|
let document = r#"[keys]
|
|
video.info.overview = "I"
|
|
video.info.hide = "x"
|
|
view.collection.remove.selected = []
|
|
search.edit = "f2"
|
|
input.delete.word = "ctrl+backspace"
|
|
input.delete.line = "ctrl+l"
|
|
"#
|
|
.parse::<toml_edit::DocumentMut>()
|
|
.unwrap();
|
|
app.apply_config(config::Config {
|
|
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
|
|
..Default::default()
|
|
});
|
|
let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers));
|
|
press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE);
|
|
assert_eq!(app.preview, Some(PreviewMode::Description));
|
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL);
|
|
assert_eq!(app.description_scroll, 1);
|
|
press(&mut app, KeyCode::Char('I'), KeyModifiers::SHIFT);
|
|
assert_eq!(app.preview, Some(PreviewMode::Info));
|
|
press(&mut app, KeyCode::Char('x'), KeyModifiers::NONE);
|
|
assert_eq!(app.preview, None);
|
|
assert!(app.description_job.is_none());
|
|
let remap = "[keys]\nvideo.select.next = 'n'\n"
|
|
.parse::<toml_edit::DocumentMut>()
|
|
.unwrap();
|
|
app.apply_config(config::Config {
|
|
keys: keys::Bindings::from_toml(remap.get("keys")).unwrap(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(app.views[0].next_start, 41);
|
|
assert_eq!(app.views[0].videos[0].title, "First");
|
|
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.apply_config(config::Config {
|
|
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
|
|
..Default::default()
|
|
});
|
|
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
press(&mut app, KeyCode::F(2), KeyModifiers::NONE);
|
|
for character in "red blue I d?".chars() {
|
|
press(&mut app, KeyCode::Char(character), KeyModifiers::NONE);
|
|
}
|
|
assert_eq!(app.views[0].query, "red blue I d?");
|
|
assert_eq!(app.preview, None);
|
|
press(&mut app, KeyCode::Backspace, KeyModifiers::CONTROL);
|
|
assert_eq!(app.views[0].query, "red blue I ");
|
|
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
|
|
assert_eq!(app.views[0].query, "red blue I ");
|
|
press(&mut app, KeyCode::Char('l'), KeyModifiers::CONTROL);
|
|
assert_eq!(app.views[0].query, "");
|
|
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
}
|
|
|
|
#[test]
|
|
fn remapped_selector_and_tag_keys_keep_input_and_modal_navigation_separate() {
|
|
let mut app = app(&["tubular"]);
|
|
let document = r#"[keys]
|
|
selector.select.next = "l"
|
|
selector.edit = "f3"
|
|
selector.close = "z"
|
|
input.submit = "ctrl+e"
|
|
input.cancel = "ctrl+b"
|
|
tags.new = "f4"
|
|
tags.cancel = "z"
|
|
"#
|
|
.parse::<toml_edit::DocumentMut>()
|
|
.unwrap();
|
|
app.apply_config(config::Config {
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers));
|
|
app.open_browser(BrowserKind::Channels);
|
|
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
|
|
press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE);
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
|
|
press(&mut app, KeyCode::F(3), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE);
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "l");
|
|
assert_eq!(app.browser.as_ref().unwrap().matches, [0]);
|
|
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
|
|
press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL);
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
|
|
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
|
|
assert!(app.browser.is_some());
|
|
press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL);
|
|
assert!(app.browser.is_none());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
assert_eq!(app.views[app.view_index].next_start, 41);
|
|
let history = app.view_history.clone();
|
|
app.category_dialog = Some(CategoryDialog {
|
|
name: None,
|
|
..CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@Beta".into(),
|
|
channel: "beta".into(),
|
|
},
|
|
"@Beta".into(),
|
|
BTreeMap::new(),
|
|
)
|
|
});
|
|
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
|
|
assert_eq!(app.view_history, history);
|
|
assert!(app.category_dialog.is_some());
|
|
press(&mut app, KeyCode::F(4), KeyModifiers::NONE);
|
|
for character in "lI?".chars() {
|
|
press(&mut app, KeyCode::Char(character), KeyModifiers::NONE);
|
|
}
|
|
press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL);
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().tags.get("lI?"),
|
|
Some(&true)
|
|
);
|
|
press(&mut app, KeyCode::Char('z'), KeyModifiers::NONE);
|
|
assert!(app.category_dialog.is_none());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_scrolls_without_navigating_and_closes_to_the_same_preview() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
browse_channel(&mut app, "Alpha");
|
|
browse_channel(&mut app, "Beta");
|
|
let history = app.view_history.clone();
|
|
let selected = app.views[app.view_index].selection.selected();
|
|
let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
|
app.views[app.view_index].has_more = true;
|
|
app.views[app.view_index].prefetch_requested = true;
|
|
assert!(app.can_prefetch());
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(!app.can_prefetch());
|
|
app.preview = Some(PreviewMode::Info);
|
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
press(&mut app, KeyCode::Char('j'));
|
|
assert_eq!(app.help_popup.as_ref().unwrap().scroll, 1);
|
|
for code in [
|
|
KeyCode::Enter,
|
|
KeyCode::Tab,
|
|
KeyCode::Char('-'),
|
|
KeyCode::Char('+'),
|
|
] {
|
|
press(&mut app, code);
|
|
}
|
|
assert_eq!(app.view_history, history);
|
|
assert!(app.forward_history.is_empty());
|
|
assert!(app.player_launch.is_none());
|
|
assert_eq!(app.views[app.view_index].selection.selected(), selected);
|
|
for _ in 0..100 {
|
|
press(&mut app, KeyCode::PageDown);
|
|
}
|
|
let help = app.help_popup.as_ref().unwrap();
|
|
assert_eq!(help.scroll, help.max_scroll);
|
|
let previous_max = help.max_scroll;
|
|
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.help_popup.as_ref().unwrap().scroll, previous_max - 1);
|
|
let mut larger = Terminal::new(TestBackend::new(120, 40)).unwrap();
|
|
larger
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let help = app.help_popup.as_ref().unwrap();
|
|
assert_eq!(help.scroll, help.max_scroll);
|
|
for _ in 0..100 {
|
|
press(&mut app, KeyCode::PageUp);
|
|
}
|
|
assert_eq!(app.help_popup.as_ref().unwrap().scroll, 0);
|
|
press(&mut app, KeyCode::Esc);
|
|
assert!(app.help_popup.is_none());
|
|
assert_eq!(app.preview, Some(PreviewMode::Info));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
press(&mut app, KeyCode::Char('-'));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("alpha".into())
|
|
);
|
|
press(&mut app, KeyCode::Char('+'));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_preserves_selector_state_and_question_mark_is_literal_in_every_input() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [("@Alpha".into(), "alpha".into())].into(),
|
|
..Default::default()
|
|
});
|
|
let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
|
app.open_browser(BrowserKind::Channels);
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(app.help_popup.is_some());
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(app.help_popup.is_none());
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
|
|
press(&mut app, KeyCode::Char('i'));
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(app.help_popup.is_none());
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "?");
|
|
assert!(app.browser.as_ref().unwrap().matches.is_empty());
|
|
press(&mut app, KeyCode::Esc);
|
|
press(&mut app, KeyCode::Esc);
|
|
press(&mut app, KeyCode::Char('i'));
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(app.help_popup.is_none());
|
|
assert_eq!(app.views[0].query, "?");
|
|
press(&mut app, KeyCode::Esc);
|
|
app.category_dialog = Some(CategoryDialog {
|
|
name: Some(String::new()),
|
|
..CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@Alpha".into(),
|
|
channel: "alpha".into(),
|
|
},
|
|
"@Alpha".into(),
|
|
BTreeMap::new(),
|
|
)
|
|
});
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(app.help_popup.is_none());
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("?")
|
|
);
|
|
press(&mut app, KeyCode::Esc);
|
|
press(&mut app, KeyCode::Char('?'));
|
|
assert!(app.help_popup.is_some());
|
|
press(&mut app, KeyCode::Esc);
|
|
assert!(app.category_dialog.is_some());
|
|
assert!(app.help_popup.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn plus_restores_forward_views_and_new_navigation_discards_the_forward_branch() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
}
|
|
browse_channel(&mut app, "Alpha");
|
|
browse_channel(&mut app, "Beta");
|
|
let press = |app: &mut App, c| {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
};
|
|
press(&mut app, '-');
|
|
press(&mut app, '-');
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
press(&mut app, '+');
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("alpha".into())
|
|
);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
|
assert_eq!(app.views[app.view_index].next_start, 41);
|
|
app.switch_view(app.view_index); // Refocusing does not discard forward history.
|
|
press(&mut app, '+');
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
press(&mut app, '+'); // End of history is a no-op.
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
press(&mut app, '-');
|
|
app.switch_view(1);
|
|
press(&mut app, '+');
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Feed);
|
|
app.mode = Mode::Insert;
|
|
press(&mut app, '+');
|
|
assert_eq!(app.views[0].query, "+");
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Feed);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn forward_navigation_skips_unsubscribed_views() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
app.views[0].loaded = true;
|
|
browse_channel(&mut app, "Alpha");
|
|
browse_channel(&mut app, "Beta");
|
|
app.back_view();
|
|
app.back_view();
|
|
app.apply_config(config::Config {
|
|
channels: [("@Beta".into(), "beta".into())].into(),
|
|
..Default::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
assert_eq!(app.temporary_view, Some(ViewSource::Channel("beta".into())));
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn minus_restores_view_history_without_consuming_modal_escape() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
categories: [("Category".into(), Vec::new())].into(),
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
}
|
|
app.switch_view(2);
|
|
browse_channel(&mut app, "Alpha");
|
|
browse_channel(&mut app, "Beta");
|
|
app.switch_view(app.view_index); // Refocusing must not add a history entry.
|
|
app.preview = Some(PreviewMode::Info);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(app.preview.is_none());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
app.mode = Mode::Insert;
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.views[0].query, "-");
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
app.open_browser(BrowserKind::Channels);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(app.browser.is_none());
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("beta".into())
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("alpha".into())
|
|
);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
|
assert_eq!(app.views[app.view_index].next_start, 41);
|
|
assert_eq!(
|
|
app.temporary_view,
|
|
Some(ViewSource::Channel("alpha".into()))
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Category".into())
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
categories: [("Earlier".into(), Vec::new()), ("Keep".into(), Vec::new())].into(),
|
|
channels: [("@Alpha".into(), "alpha".into())].into(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
for view in &mut app.views {
|
|
view.loaded = true;
|
|
}
|
|
app.switch_view(3); // Keep initially follows Earlier.
|
|
browse_channel(&mut app, "Alpha");
|
|
app.apply_config(config::Config {
|
|
categories: [("Keep".into(), Vec::new())].into(),
|
|
channels: [("@Alpha".into(), "alpha".into())].into(),
|
|
..Default::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Keep".into())
|
|
);
|
|
browse_channel(&mut app, "Alpha");
|
|
app.apply_config(config::Config {
|
|
channels: [("@Alpha".into(), "alpha".into())].into(),
|
|
..Default::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
|
let mut app = app(&["tubular"]);
|
|
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
|
|
app.apply_config(config::Config {
|
|
channels: [("@Saved".into(), identity.into())].into(),
|
|
..Default::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Saved"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Description);
|
|
let press = |app: &mut App| {
|
|
app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
|
|
};
|
|
press(&mut app);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel(identity.into())
|
|
);
|
|
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
|
|
assert_eq!(app.views[app.view_index].next_start, 41);
|
|
assert_eq!(app.views[app.view_index].videos[1].title, "Second");
|
|
assert!(app.preview.is_none());
|
|
assert!(app.job.is_none());
|
|
let count = app.views.len();
|
|
press(&mut app);
|
|
assert_eq!(app.views.len(), count);
|
|
app.switch_view(0);
|
|
app.views[0].videos[0].channel_id = None;
|
|
press(&mut app); // A handle-only row resolves through the saved mapping.
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel(identity.into())
|
|
);
|
|
app.switch_view(0);
|
|
app.views[0].videos[0].uploader_id = Some("@invalid/handle".into());
|
|
press(&mut app);
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views.len(), count);
|
|
assert!(app.job.is_none());
|
|
app.views[0].videos[0].uploader_id = None;
|
|
press(&mut app);
|
|
assert_eq!(app.view_index, 0);
|
|
app.mode = Mode::Insert;
|
|
press(&mut app);
|
|
assert_eq!(app.views[0].query, "U");
|
|
assert_eq!(app.view_index, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn unsaved_video_channels_survive_config_changes_without_subscribing() {
|
|
let mut app = app(&["tubular"]);
|
|
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
|
|
let mut channel = View::new("@Unsaved".into(), ViewSource::Channel(identity.into()));
|
|
channel.loaded = true;
|
|
app.views.push(channel);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Unsaved"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].selection.select(Some(0));
|
|
app.views[0].loaded = true;
|
|
app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel(identity.into())
|
|
);
|
|
assert!(app.config.subscriptions().is_empty());
|
|
app.apply_config(config::Config {
|
|
categories: [("Unrelated".into(), Vec::new())].into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel(identity.into())
|
|
);
|
|
assert!(app.views[app.view_index].loaded);
|
|
assert_eq!(
|
|
app.temporary_view,
|
|
Some(ViewSource::Channel(identity.into()))
|
|
);
|
|
app.apply_config(config::Config {
|
|
channels: [("@Registered".into(), identity.into())].into(),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(app.views[app.view_index].name, "@Registered");
|
|
assert!(app.views[app.view_index].loaded);
|
|
app.apply_config(config::Config::default());
|
|
assert_eq!(app.view_index, 0);
|
|
assert!(
|
|
app.views
|
|
.iter()
|
|
.all(|view| view.source != ViewSource::Channel(identity.into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn channels_filter_in_both_modes_without_pinning_or_running_modal_shortcuts() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [
|
|
("@jazz".into(), "jazz identity".into()),
|
|
("@jokes".into(), "jokes identity".into()),
|
|
]
|
|
.into(),
|
|
..config::Config::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
app.views[0].query = "untouched".into();
|
|
app.views[0].loaded = true;
|
|
app.views[0].has_more = true;
|
|
app.preview = Some(PreviewMode::Info);
|
|
assert!(!app.can_prefetch());
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
|
|
assert!(app.preview.is_none());
|
|
assert!(!app.can_prefetch());
|
|
app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
|
assert!(app.config.pinned_categories.is_empty());
|
|
assert!(app.browser.as_ref().unwrap().error.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
for c in "jkp".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "jkp");
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None);
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 0);
|
|
assert!(app.browser.is_some());
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
app.key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE));
|
|
for (code, modifiers, row) in [
|
|
(KeyCode::Char('j'), KeyModifiers::CONTROL, 1),
|
|
(KeyCode::Char('k'), KeyModifiers::CONTROL, 0),
|
|
(KeyCode::Down, KeyModifiers::NONE, 1),
|
|
(KeyCode::Up, KeyModifiers::NONE, 0),
|
|
] {
|
|
app.key(KeyEvent::new(code, modifiers));
|
|
assert_eq!(
|
|
app.browser.as_ref().unwrap().selection.selected(),
|
|
Some(row)
|
|
);
|
|
}
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "J");
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal);
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel("jazz identity".into())
|
|
);
|
|
assert_eq!(
|
|
app.views[app.view_index].source.channels(
|
|
&app.config,
|
|
&app.feed_channels,
|
|
&app.views[app.view_index].group_channels
|
|
),
|
|
["jazz identity"]
|
|
);
|
|
assert_eq!(app.views[0].query, "untouched");
|
|
assert!(app.browser.is_none());
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_and_category_share_temporary_tab_and_cached_history() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
categories: [
|
|
("@Alpha".into(), vec!["beta".into()]),
|
|
("Temporary".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: vec!["@Alpha".into()],
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
app.views[2].loaded = true;
|
|
app.switch_view(3);
|
|
browse_channel(&mut app, "Alpha");
|
|
let alpha_source = ViewSource::Channel("alpha".into());
|
|
assert_eq!(app.views[app.view_index].source, alpha_source);
|
|
assert_eq!(
|
|
app.views[app.view_index].source.channels(
|
|
&app.config,
|
|
&app.feed_channels,
|
|
&app.views[app.view_index].group_channels
|
|
),
|
|
["alpha"]
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
assert!(app.category_dialog.is_none());
|
|
assert_eq!(app.views[app.view_index].source, alpha_source);
|
|
browse_channel(&mut app, "Beta");
|
|
let visible: Vec<_> = app
|
|
.visible_views()
|
|
.map(|(_, view)| view.source.clone())
|
|
.collect();
|
|
assert_eq!(
|
|
visible,
|
|
[
|
|
ViewSource::Search,
|
|
ViewSource::Feed,
|
|
ViewSource::Category("@Alpha".into()),
|
|
ViewSource::Channel("beta".into())
|
|
]
|
|
);
|
|
assert!(app.views.iter().any(|view| view.source == alpha_source));
|
|
browse_channel(&mut app, "Alpha");
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, alpha_source);
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.next_start, 41);
|
|
assert_eq!(view.videos.len(), 2);
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Category("Temporary".into())
|
|
);
|
|
assert!(
|
|
!app.visible_views()
|
|
.any(|(_, view)| matches!(view.source, ViewSource::Channel(_)))
|
|
);
|
|
browse_channel(&mut app, "Alpha");
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
|
|
assert_eq!(app.views[app.view_index].source, alpha_source);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_refresh_matches_identities_relabels_caches_and_removes_unsubscribed_views() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [
|
|
("@Alpha".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
categories: [("@Alpha".into(), vec!["beta".into()])].into(),
|
|
..config::Config::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
browse_channel(&mut app, "Alpha");
|
|
app.views[app.view_index].has_more = true;
|
|
app.views[app.view_index].feed_requests =
|
|
app.views[app.view_index].feed_cursor.plan(1).unwrap();
|
|
app.views[app.view_index].prefetch_requested = true;
|
|
app.views[app.view_index].page_error = Some("kept".into());
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: [
|
|
("@Renamed".into(), "alpha".into()),
|
|
("@Beta".into(), "beta".into()),
|
|
]
|
|
.into(),
|
|
categories: [
|
|
("New tag".into(), vec!["alpha".into()]),
|
|
("@Alpha".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: vec!["@Alpha".into()],
|
|
});
|
|
let view = &app.views[app.view_index];
|
|
assert_eq!(view.source, ViewSource::Channel("alpha".into()));
|
|
assert_eq!(view.name, "@Renamed");
|
|
assert_eq!(view.title, " @Renamed ");
|
|
assert!(view.loaded && view.has_more);
|
|
assert_eq!(view.next_start, 41);
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.feed_requests.len(), 1);
|
|
assert!(view.prefetch_requested);
|
|
assert_eq!(view.page_error.as_deref(), Some("kept"));
|
|
let beta = app
|
|
.views
|
|
.iter()
|
|
.find(|view| view.source == ViewSource::Channel("beta".into()))
|
|
.unwrap();
|
|
assert_eq!(beta.next_start, 41);
|
|
assert_eq!(beta.selection.selected(), Some(1));
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: [("@Beta".into(), "beta".into())].into(),
|
|
categories: [("@Alpha".into(), vec!["beta".into()])].into(),
|
|
pinned_categories: vec!["@Alpha".into()],
|
|
});
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
|
|
assert!(app.temporary_view.is_none());
|
|
assert!(
|
|
!app.views
|
|
.iter()
|
|
.any(|view| view.source == ViewSource::Channel("alpha".into()))
|
|
);
|
|
browse_channel(&mut app, "Beta");
|
|
assert_eq!(app.views[app.view_index].next_start, 41);
|
|
app.apply_config(config::Config::default());
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views.len(), 2);
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
|
|
for code in [KeyCode::Down, KeyCode::Enter, KeyCode::Char('p')] {
|
|
app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
|
}
|
|
assert!(app.browser.as_ref().unwrap().choices.is_empty());
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(app.browser.is_none());
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_labels_cannot_alias_builtin_or_category_sources() {
|
|
let mut app = app(&["tubular"]);
|
|
// Raw fixture identities exercise equal labels without invoking an extractor.
|
|
app.apply_config(config::Config {
|
|
categories: [
|
|
("Feed".into(), vec!["Feed".into(), "Search".into()]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: vec!["Feed".into(), "Search".into()],
|
|
..config::Config::default()
|
|
});
|
|
loaded_channel_views(&mut app);
|
|
for label in ["Feed", "Search"] {
|
|
browse_channel(&mut app, label);
|
|
assert_eq!(
|
|
app.views[app.view_index].source,
|
|
ViewSource::Channel(label.into())
|
|
);
|
|
assert_eq!(
|
|
app.views[app.view_index].source.channels(
|
|
&app.config,
|
|
&app.feed_channels,
|
|
&app.views[app.view_index].group_channels
|
|
),
|
|
[label]
|
|
);
|
|
assert_eq!(
|
|
app.visible_views()
|
|
.filter(|(_, view)| view.name == label)
|
|
.count(),
|
|
3
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
assert!(app.category_dialog.is_none());
|
|
}
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_selector_renders_plain_labels_modes_empty_states_and_filter_cursor() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
channels: [("@Alpha".into(), "alpha".into())].into(),
|
|
..config::Config::default()
|
|
});
|
|
let mut terminal = Terminal::new(TestBackend::new(180, 40)).unwrap();
|
|
let text = |terminal: &Terminal<TestBackend>| -> String {
|
|
terminal
|
|
.backend()
|
|
.buffer()
|
|
.content
|
|
.iter()
|
|
.map(|cell| cell.symbol())
|
|
.collect()
|
|
};
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let rendered = text(&terminal);
|
|
assert!(rendered.contains("@Alpha"));
|
|
assert!(!rendered.contains("[p]") && !rendered.contains("[ ]"));
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let popup = preview_area(Rect::new(0, 0, 180, 40));
|
|
assert_eq!(
|
|
terminal.get_cursor_position().unwrap(),
|
|
(popup.x + 3, popup.y + 2).into()
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.apply_config(config::Config::default());
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert!(app.job.is_none());
|
|
}
|
|
#[test]
|
|
fn filtered_tagging_saves_visible_changes_without_losing_hidden_memberships() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
|
|
for bulk in [false, true] {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"tubular-filtered-tags-{}-{bulk}.toml",
|
|
std::process::id()
|
|
));
|
|
let original = "[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@a', '@b']\nGamma = []\nZebra = ['@a', '@b']\n";
|
|
std::fs::write(&path, original).unwrap();
|
|
let mut app = App::new(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config::load(&path).unwrap(),
|
|
);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Creator video","channel":"Creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@a"}]}"#
|
|
).unwrap().videos;
|
|
app.views[0].selection.select(Some(0));
|
|
if bulk {
|
|
app.view_index = app
|
|
.views
|
|
.iter()
|
|
.position(|view| view.name == "Alpha")
|
|
.unwrap();
|
|
}
|
|
app.views[app.view_index].loaded = true;
|
|
let press = |app: &mut App, code, modifiers| {
|
|
app.key(KeyEvent::new(code, modifiers));
|
|
};
|
|
press(
|
|
&mut app,
|
|
KeyCode::Char(if bulk { 'S' } else { 's' }),
|
|
KeyModifiers::NONE,
|
|
);
|
|
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
|
|
for c in "missing".chars() {
|
|
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
|
|
}
|
|
let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let text: String = terminal
|
|
.backend()
|
|
.buffer()
|
|
.content
|
|
.iter()
|
|
.map(|cell| cell.symbol())
|
|
.collect();
|
|
assert!(text.contains("No matching tags."));
|
|
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
|
|
for c in "GA".chars() {
|
|
press(&mut app, KeyCode::Char(c), KeyModifiers::SHIFT);
|
|
}
|
|
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
|
|
assert!(app.category_dialog.is_some(), "filter Enter must not save");
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
|
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let text: String = terminal
|
|
.backend()
|
|
.buffer()
|
|
.content
|
|
.iter()
|
|
.map(|cell| cell.symbol())
|
|
.collect();
|
|
assert!(text.contains("[x] Gamma"));
|
|
assert!(!text.contains("[x] Alpha") && !text.contains("[x] Zebra"));
|
|
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
|
|
assert!(app.category_dialog.is_none());
|
|
let saved = config::load(&path).unwrap();
|
|
let a = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap();
|
|
let b = config::normalize_channel("UCaYhcUwRBNscFNUKTjgPFiA").unwrap();
|
|
assert_eq!(saved.categories["Alpha"], [a.clone(), b.clone()]);
|
|
assert_eq!(saved.categories["Zebra"], [a.clone(), b.clone()]);
|
|
assert_eq!(
|
|
saved.categories["Gamma"],
|
|
if bulk { vec![a, b] } else { vec![a] }
|
|
);
|
|
std::fs::remove_file(path).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tag_filter_preserves_selection_and_stages_new_tags_outside_the_filter() {
|
|
let mut app = app(&["tubular"]);
|
|
app.category_dialog = Some(CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
handle: "@a".into(),
|
|
channel: String::new(),
|
|
},
|
|
"Creator".into(),
|
|
[
|
|
("Alpha".into(), false),
|
|
("Gamma".into(), true),
|
|
("Zebra".into(), false),
|
|
]
|
|
.into(),
|
|
));
|
|
let press = |app: &mut App, code, modifiers| {
|
|
app.key(KeyEvent::new(code, modifiers));
|
|
};
|
|
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('a'), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL);
|
|
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["Zebra"]);
|
|
assert!(!app.category_dialog.as_ref().unwrap().tags["Alpha"]);
|
|
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
|
|
for c in "gamma".chars() {
|
|
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
|
|
}
|
|
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
|
|
press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE);
|
|
for c in "New".chars() {
|
|
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
|
|
}
|
|
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
|
|
let dialog = app.category_dialog.as_ref().unwrap();
|
|
assert!(dialog.tags["New"]);
|
|
assert!(dialog.query.is_empty(), "new tag must be visible");
|
|
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
|
|
assert!(
|
|
!app.category_dialog.as_ref().unwrap().tags["New"],
|
|
"new tag is selected"
|
|
);
|
|
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
|
|
assert!(app.category_dialog.is_none());
|
|
assert!(
|
|
app.config.categories.is_empty(),
|
|
"cancel discards staged changes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_picker_uses_all_saved_channels_and_commits_or_cancels_the_whole_group() {
|
|
let path =
|
|
std::env::temp_dir().join(format!("tubular-bulk-picker-{}.toml", std::process::id()));
|
|
let original = "[settings]\npinned_categories = ['Source']\n[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@other' = 'UCBR8-60-B28hp2BmDPdntcQ'\n[categories]\nEmpty = []\nMixed = ['@a', '@other']\nShared = ['@a', '@b', '@other']\nSource = ['@a', '@b']\n";
|
|
std::fs::write(&path, original).unwrap();
|
|
let mut app = App::new(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config::load(&path).unwrap(),
|
|
);
|
|
loaded_channel_views(&mut app);
|
|
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
assert!(app.category_dialog.is_none());
|
|
app.view_index = app
|
|
.views
|
|
.iter()
|
|
.position(|view| view.name == "Source")
|
|
.unwrap();
|
|
app.views[app.view_index].loaded = true;
|
|
app.views[1].loaded = true;
|
|
app.views[1].next_start = 41;
|
|
let subscriptions = app.feed_channels.clone();
|
|
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
let dialog = app.category_dialog.as_ref().unwrap();
|
|
assert!(dialog.tags["Shared"] && dialog.tags["Source"]);
|
|
assert!(!dialog.tags["Mixed"] && !dialog.tags["Empty"]);
|
|
assert!(
|
|
matches!(&dialog.target, CategoryTarget::Category { channels, .. } if channels.len() == 2)
|
|
);
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
for c in "Staged".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
|
assert!(!app.config.categories.contains_key("Staged"));
|
|
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_none());
|
|
let saved = config::load(&path).unwrap();
|
|
assert!(saved.categories["Source"].is_empty());
|
|
assert_eq!(
|
|
saved.categories["Mixed"],
|
|
["https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"]
|
|
);
|
|
assert_eq!(saved.categories["Shared"].len(), 3);
|
|
assert_eq!(saved.pinned_categories, ["Source"]);
|
|
assert_eq!(app.feed_channels, subscriptions);
|
|
assert!(app.views[1].loaded);
|
|
assert_eq!(app.views[1].next_start, 41);
|
|
browse_channel(&mut app, "@a");
|
|
let channel_source = app.views[app.view_index].source.clone();
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
let channel = &app.views[app.view_index];
|
|
assert_eq!(channel.source, channel_source);
|
|
assert!(channel.loaded);
|
|
assert_eq!(channel.videos.len(), 2);
|
|
assert_eq!(channel.selection.selected(), Some(1));
|
|
assert_eq!(channel.next_start, 41);
|
|
assert!(app.job.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
|
assert!(app.category_dialog.is_none());
|
|
std::fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn filtering_preserves_selected_categories_and_browses_the_filtered_row() {
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].query = "main search untouched".into();
|
|
app.apply_config(config::Config {
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Japanese immersion".into(), vec![]),
|
|
("Japanese news".into(), vec![]),
|
|
]
|
|
.into(),
|
|
..config::Config::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
for c in "JAPANESE".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
|
|
for c in " news".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal);
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].name, "Japanese news");
|
|
assert_eq!(app.views[0].query, "main search untouched");
|
|
assert!(app.browser.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn insert_filter_types_jk_handles_empty_matches_and_navigates_without_editing() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Jazz".into(), vec![]),
|
|
("Jokes".into(), vec![]),
|
|
("日本語だけで".into(), vec![]),
|
|
]
|
|
.into(),
|
|
..config::Config::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
for c in "jk".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
assert!(app.browser.as_ref().unwrap().selection.selected().is_none());
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.browser.is_some());
|
|
assert_eq!(app.view_index, 0);
|
|
app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
for (code, modifiers, row) in [
|
|
(KeyCode::Char('j'), KeyModifiers::CONTROL, 1),
|
|
(KeyCode::Char('k'), KeyModifiers::CONTROL, 0),
|
|
(KeyCode::Down, KeyModifiers::NONE, 1),
|
|
(KeyCode::Up, KeyModifiers::NONE, 0),
|
|
] {
|
|
app.key(KeyEvent::new(code, modifiers));
|
|
assert_eq!(
|
|
app.browser.as_ref().unwrap().selection.selected(),
|
|
Some(row)
|
|
);
|
|
}
|
|
assert_eq!(app.browser.as_ref().unwrap().query, "j");
|
|
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
|
for c in "日本".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].name, "日本語だけで");
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
}
|
|
|
|
#[test]
|
|
fn category_browser_replaces_the_temporary_tab_and_cycles_only_visible_views() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Beta".into(), vec![]),
|
|
("Gamma".into(), vec![]),
|
|
]
|
|
.into(),
|
|
..config::Config::default()
|
|
});
|
|
let visible = |app: &App| {
|
|
app.visible_views()
|
|
.map(|(_, view)| view.name.clone())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
assert_eq!(visible(&app), ["Search", "Feed"]);
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 0);
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.views[app.view_index].name, "Beta");
|
|
assert_eq!(visible(&app), ["Search", "Feed", "Beta"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(visible(&app), ["Search", "Feed", "Gamma"]);
|
|
app.apply_config(config::Config {
|
|
categories: app.config.categories.clone(),
|
|
pinned_categories: vec!["Beta".into()],
|
|
..config::Config::default()
|
|
});
|
|
assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Gamma"]);
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 0);
|
|
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
|
|
assert_eq!(app.views[app.view_index].name, "Gamma");
|
|
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
|
|
assert_eq!(app.views[app.view_index].name, "Beta");
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(visible(&app), ["Search", "Feed", "Beta"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(visible(&app), ["Search", "Feed", "Beta"]);
|
|
assert!(app.browser.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
|
|
}
|
|
|
|
#[test]
|
|
fn categories_named_search_or_feed_are_distinct_from_permanent_views() {
|
|
let mut app = app(&["tubular"]);
|
|
app.apply_config(config::Config {
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
..config::Config::default()
|
|
});
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 3);
|
|
assert_eq!(app.views[app.view_index].name, "Feed");
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn descriptions_are_shared_across_views_and_evict_least_recently_used_entries() {
|
|
let mut app = app(&["tubular"]);
|
|
let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared video"}]}"#;
|
|
for view in &mut app.views {
|
|
view.videos = youtube::search_results(output).unwrap().videos;
|
|
view.selection.select(Some(0));
|
|
view.loaded = true;
|
|
}
|
|
app.remember_description("jNQXAC9IVRw".into(), "Shared full description".into());
|
|
for index in 1..DESCRIPTION_CACHE_SIZE {
|
|
app.remember_description(format!("video{index:06}"), format!("Description {index}"));
|
|
}
|
|
app.switch_view(1);
|
|
app.load_description(); // A hit in Feed promotes the Search video's description.
|
|
assert!(app.description_job.is_none());
|
|
app.remember_description("new00000000".into(), "New description".into());
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "jNQXAC9IVRw"),
|
|
Some("Shared full description")
|
|
);
|
|
assert_eq!(cached_description(&app.descriptions, "video000001"), None);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "video000002"),
|
|
Some("Description 2")
|
|
);
|
|
// Updating an existing entry, including a known empty description, must not evict another.
|
|
app.remember_description("jNQXAC9IVRw".into(), String::new());
|
|
app.load_description();
|
|
assert!(app.description_job.is_none());
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "jNQXAC9IVRw"),
|
|
Some("")
|
|
);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "video000002"),
|
|
Some("Description 2")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() {
|
|
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Heading","channel":"Creator","upload_date":"20050423"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Info);
|
|
for (last_line, hidden) in [
|
|
("line", false),
|
|
("line", true),
|
|
("abcdefghijklmnopqrs", true),
|
|
("a界界界界界界界界界", true),
|
|
("", true),
|
|
] {
|
|
app.remember_description(
|
|
"jNQXAC9IVRw".into(),
|
|
format!(
|
|
"{}{last_line}{}",
|
|
"line\n".repeat(6),
|
|
if hidden { "\nhidden content" } else { "" },
|
|
),
|
|
);
|
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let popup = preview_area(buffer.area);
|
|
let text_x = (popup.x + 1..popup.right() - 1)
|
|
.find(|&x| {
|
|
let cell = &buffer[(x, popup.y + 1)];
|
|
cell.symbol() == "H" && cell.modifier.contains(Modifier::BOLD)
|
|
})
|
|
.expect("title is visible");
|
|
let row: String = (text_x..popup.right() - 3)
|
|
.map(|x| buffer[(x, popup.bottom() - 2)].symbol())
|
|
.collect();
|
|
if hidden {
|
|
assert!(row.trim_end().ends_with('…'), "{row}");
|
|
if last_line.starts_with("a界") {
|
|
assert!(row.starts_with("a界"));
|
|
for x in text_x..popup.right() - 3 {
|
|
if buffer[(x, popup.bottom() - 2)].symbol() == "界" {
|
|
assert_eq!(buffer[(x + 1, popup.bottom() - 2)].symbol(), " ");
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
assert_eq!(row.trim_end(), "line");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() {
|
|
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#,
|
|
).unwrap().videos;
|
|
app.remember_description(
|
|
"jNQXAC9IVRw".into(),
|
|
format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)),
|
|
);
|
|
app.views[0].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Info);
|
|
for (width, height) in [(80, 24), (120, 40), (20, 10)] {
|
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let popup = preview_area(Rect::new(0, 0, width, height));
|
|
let mut excerpt_rows = Vec::new();
|
|
for y in popup.y + 1..popup.bottom() - 1 {
|
|
for x in popup.right() - 3..popup.right() - 1 {
|
|
assert_eq!(buffer.cell((x, y)).unwrap().symbol(), " ");
|
|
}
|
|
let row: String = (popup.x + popup.width / 2..popup.right() - 3)
|
|
.map(|x| buffer.cell((x, y)).unwrap().symbol())
|
|
.collect();
|
|
assert!(!row.contains("CLIPPED SENTINEL"));
|
|
if row.contains("Excerpt line") {
|
|
excerpt_rows.push(y);
|
|
}
|
|
}
|
|
if width == 20 {
|
|
assert!(excerpt_rows.is_empty()); // Heading takes all available height.
|
|
continue;
|
|
}
|
|
let text_x = (popup.x + 1..popup.right() - 1)
|
|
.find(|&x| {
|
|
let cell = &buffer[(x, popup.y + 1)];
|
|
cell.symbol() == "F" && cell.modifier.contains(Modifier::BOLD)
|
|
})
|
|
.expect("title is visible");
|
|
let title = &buffer[(text_x, popup.y + 1)];
|
|
assert!(title.modifier.contains(Modifier::BOLD));
|
|
assert!(!title.modifier.contains(Modifier::DIM));
|
|
let excerpt_y = *excerpt_rows
|
|
.last()
|
|
.expect("description fills remaining space");
|
|
assert_eq!(excerpt_y, popup.bottom() - 2);
|
|
let excerpt = &buffer[(text_x, excerpt_y)];
|
|
assert!(!excerpt.modifier.contains(Modifier::DIM));
|
|
assert!(!excerpt.modifier.contains(Modifier::BOLD));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn view_tabs_wrap_whole_labels_and_keep_the_selected_row_visible() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
app.views = ["Search", "Feed", "Language immersion", "日本語だけ", "中文"]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, name)| {
|
|
View::new(
|
|
name.into(),
|
|
match index {
|
|
0 => ViewSource::Search,
|
|
1 => ViewSource::Feed,
|
|
_ => ViewSource::Category(name.into()),
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
app.config.pinned_categories = app
|
|
.views
|
|
.iter()
|
|
.skip(2)
|
|
.map(|view| view.name.clone())
|
|
.collect();
|
|
app.view_index = 4;
|
|
app.mode = Mode::Insert;
|
|
for (width, height) in [(30, 20), (56, 20), (55, 20), (9, 20), (30, 12)] {
|
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let tab_height = terminal.get_cursor_position().unwrap().y - 1;
|
|
let buffer = terminal.backend().buffer();
|
|
let locate = |symbol: &str| {
|
|
(0..tab_height)
|
|
.flat_map(|y| (0..width).map(move |x| (x, y)))
|
|
.find(|&position| buffer.cell(position).unwrap().symbol() == symbol)
|
|
};
|
|
let chinese = locate("中").expect("selected category remains visible");
|
|
assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan);
|
|
assert_eq!(
|
|
buffer.cell((chinese.0 + 2, chinese.1)).unwrap().symbol(),
|
|
"文"
|
|
);
|
|
if width >= 16 {
|
|
let japanese = locate("日").expect("neighboring category remains visible");
|
|
for (offset, character) in "日本語だけ".chars().enumerate() {
|
|
assert_eq!(
|
|
buffer
|
|
.cell((japanese.0 + offset as u16 * 2, japanese.1))
|
|
.unwrap()
|
|
.symbol(),
|
|
character.to_string(),
|
|
"category label must not split across rows"
|
|
);
|
|
}
|
|
assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan);
|
|
}
|
|
if width >= 24 {
|
|
assert!(
|
|
(0..tab_height).any(|y| {
|
|
(0..width)
|
|
.map(|x| buffer.cell((x, y)).unwrap().symbol())
|
|
.collect::<String>()
|
|
.contains("Language immersion")
|
|
}),
|
|
"whole category label must fit after wrapping"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_pages_preserve_relevance_and_selection() {
|
|
let mut view = View::new("Search".into(), ViewSource::Search);
|
|
let mut first = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#,
|
|
)
|
|
.unwrap();
|
|
first.has_more = true;
|
|
view.accept_page(first, false).unwrap();
|
|
view.selection.select(Some(1));
|
|
let mut next = youtube::search_results(
|
|
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Duplicate"},{"id":"l-GaaivVJCE","title":"Relevant","timestamp":50},{"id":"l-GaaivVJCE","title":"Repeated"},{"id":"aaaaaaaaaaa","title":"Later","timestamp":300}]}"#,
|
|
)
|
|
.unwrap();
|
|
next.has_more = true;
|
|
view.accept_page(next, false).unwrap();
|
|
assert_eq!(
|
|
view.videos
|
|
.iter()
|
|
.map(|video| video.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE", "aaaaaaaaaaa"]
|
|
);
|
|
assert_eq!(view.videos[1].title, "Second");
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.next_start, PAGE_SIZE * 2 + 1);
|
|
assert!(view.has_more);
|
|
}
|
|
|
|
#[test]
|
|
fn many_channel_feed_consumes_only_sixty_requested_rows() {
|
|
let mut app = app(&["tubular"]);
|
|
app.view_index = 1;
|
|
app.feed_channels = (0..75)
|
|
.map(|index| format!("https://www.youtube.com/@creator{index}"))
|
|
.collect();
|
|
let view = &mut app.views[1];
|
|
view.feed_requests = view.feed_cursor.plan(app.feed_channels.len()).unwrap();
|
|
assert_eq!(
|
|
view.feed_requests
|
|
.iter()
|
|
.map(|request| request.count)
|
|
.sum::<usize>(),
|
|
PAGE_SIZE
|
|
);
|
|
assert_eq!(view.feed_requests.len(), PAGE_SIZE);
|
|
let output = view
|
|
.feed_requests
|
|
.iter()
|
|
.map(|request| {
|
|
serde_json::json!({"entries":[{
|
|
"id":format!("channel{:04}", request.channel),
|
|
"title":"Upload",
|
|
"timestamp":request.channel,
|
|
}]})
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
app.finish_page(Ok(output));
|
|
assert_eq!(app.views[1].videos.len(), PAGE_SIZE);
|
|
assert!(app.views[1].has_more);
|
|
assert!(!app.can_prefetch());
|
|
let next = app.views[1]
|
|
.feed_cursor
|
|
.plan(app.feed_channels.len())
|
|
.unwrap();
|
|
assert!(
|
|
next.iter()
|
|
.any(|request| request.channel >= PAGE_SIZE && request.start == 1)
|
|
);
|
|
assert_eq!(
|
|
next.iter().map(|request| request.count).sum::<usize>(),
|
|
PAGE_SIZE
|
|
);
|
|
app.views[1].feed_requests = next.clone();
|
|
app.views[1].prefetch_requested = true;
|
|
app.finish_page(Ok(r#"{"entries":[]}"#.into()));
|
|
assert!(app.views[1].page_error.is_some());
|
|
assert_eq!(app.views[1].videos.len(), PAGE_SIZE);
|
|
assert_eq!(app.views[1].next_start, PAGE_SIZE + 1);
|
|
assert_eq!(
|
|
app.views[1]
|
|
.feed_cursor
|
|
.plan(app.feed_channels.len())
|
|
.unwrap(),
|
|
next
|
|
);
|
|
assert!(app.views[1].prefetch_requested);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_keeps_rows_until_a_valid_replacement_arrives() {
|
|
let mut app = app(&["tubular"]);
|
|
app.view_index = 1;
|
|
// Duplicate URLs fail request creation before any extractor is spawned.
|
|
app.feed_channels = vec!["fixture".into(), "fixture".into()];
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old"},{"id":"rQ_J9WH6CGk","title":"Selected"}]}
|
|
{"entries":[]}"#,
|
|
false,
|
|
);
|
|
app.views[1].selection.select(Some(1));
|
|
app.load_view();
|
|
assert_eq!(app.views[1].videos[1].title, "Selected");
|
|
assert_eq!(app.views[1].selection.selected(), Some(1));
|
|
assert!(app.views[1].page_error.is_some());
|
|
assert_eq!(app.views[1].next_start, 1);
|
|
|
|
app.finish_page(Ok("not JSON".into()));
|
|
assert_eq!(app.views[1].videos[1].title, "Selected");
|
|
assert_eq!(app.views[1].selection.selected(), Some(1));
|
|
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Updated"}]}
|
|
{"entries":[]}"#,
|
|
true,
|
|
);
|
|
assert_eq!(app.views[1].videos.len(), 1);
|
|
assert_eq!(app.views[1].videos[0].title, "Updated");
|
|
assert_eq!(app.views[1].selection.selected(), Some(0));
|
|
assert_eq!(app.views[1].next_start, PAGE_SIZE + 1);
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Next page"}]}
|
|
{"entries":[]}"#,
|
|
false,
|
|
);
|
|
assert_eq!(app.views[1].videos[0].title, "Updated");
|
|
assert_eq!(app.views[1].videos[1].title, "Next page");
|
|
|
|
app.load_view();
|
|
assert_eq!(app.views[1].videos[0].title, "Updated");
|
|
finish_feed_fixture(&mut app, "{\"entries\":[]}\n{\"entries\":[]}", false);
|
|
assert!(app.views[1].videos.is_empty());
|
|
assert_eq!(app.views[1].selection.selected(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() {
|
|
let mut app = app(&["tubular"]);
|
|
app.view_index = 1;
|
|
app.feed_channels = vec![
|
|
"https://www.youtube.com/@one".into(),
|
|
"https://www.youtube.com/@two".into(),
|
|
];
|
|
// Keep both channels active despite a short visible page.
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Undated"}]}
|
|
{"entries":[{"id":"l-GaaivVJCE","title":"Middle","timestamp":200}]}"#,
|
|
true,
|
|
);
|
|
assert_eq!(
|
|
app.views[1]
|
|
.videos
|
|
.iter()
|
|
.map(|video| video.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"]
|
|
);
|
|
app.views[1].selection.select(Some(1));
|
|
let description_id = app.views[1].videos[1].id.clone();
|
|
app.preview = Some(PreviewMode::Info);
|
|
app.remember_description("l-GaaivVJCE".into(), "Keep cached preview".into());
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Newer","timestamp":300}]}
|
|
{"entries":[]}"#,
|
|
false,
|
|
);
|
|
assert_eq!(
|
|
app.views[1]
|
|
.videos
|
|
.iter()
|
|
.map(|video| video.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"]
|
|
);
|
|
assert_eq!(app.views[1].selection.selected(), Some(2));
|
|
assert_eq!(app.views[1].videos[2].title, "Old");
|
|
app.finish_description(
|
|
description_id,
|
|
Ok(r#"{"id":"jNQXAC9IVRw","description":"Selected upload description"}"#.into()),
|
|
);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "jNQXAC9IVRw"),
|
|
Some("Selected upload description")
|
|
);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "l-GaaivVJCE"),
|
|
Some("Keep cached preview")
|
|
);
|
|
assert_eq!(cached_description(&app.descriptions, "SLV9AJxnb1U"), None);
|
|
assert_eq!(app.preview, Some(PreviewMode::Info));
|
|
assert!(!app.views[1].has_more);
|
|
}
|
|
|
|
#[test]
|
|
fn short_undated_or_invalid_feed_pages_require_new_forward_navigation() {
|
|
for visible in [0, 1, 19] {
|
|
let mut app = app(&["tubular"]);
|
|
app.view_index = 1;
|
|
app.feed_channels
|
|
.push("https://www.youtube.com/@jawed".into());
|
|
let mut entries: Vec<_> = (0..visible)
|
|
.map(|index| {
|
|
serde_json::json!({
|
|
"id":format!("unknown{index:04}"), "title":"Undated",
|
|
})
|
|
})
|
|
.collect();
|
|
entries.resize(PAGE_SIZE - 1, serde_json::Value::Null);
|
|
entries.push(serde_json::json!({"id":"bad", "title":"Invalid ID"}));
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
&serde_json::json!({"entries":entries}).to_string(),
|
|
false,
|
|
);
|
|
assert_eq!(app.views[1].videos.len(), visible);
|
|
assert!(app.views[1].has_more);
|
|
assert!(!app.can_prefetch());
|
|
app.update();
|
|
assert!(app.job.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
assert!(app.can_prefetch());
|
|
assert_eq!(
|
|
app.views[1].selection.selected(),
|
|
if visible == 0 {
|
|
None
|
|
} else {
|
|
Some((visible - 1).min(1))
|
|
}
|
|
);
|
|
finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true);
|
|
assert!(app.views[1].has_more);
|
|
assert!(!app.can_prefetch());
|
|
app.key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT));
|
|
assert!(app.views[1].prefetch_requested);
|
|
let next = app.views[1].feed_cursor.plan(1).unwrap();
|
|
app.finish_page(Err("Continuation failed".into()));
|
|
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next);
|
|
assert!(!app.can_prefetch());
|
|
app.views[1].reset();
|
|
assert!(app.views[1].feed_requests.is_empty());
|
|
assert!(!app.views[1].prefetch_requested);
|
|
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn feed_cursors_and_staged_requests_survive_cancellation_and_history_independently() {
|
|
let mut app = app(&["tubular"]);
|
|
app.feed_channels
|
|
.push("https://www.youtube.com/@one".into());
|
|
app.config
|
|
.categories
|
|
.insert("Two".into(), vec!["https://www.youtube.com/@two".into()]);
|
|
app.views
|
|
.push(View::new("Two".into(), ViewSource::Category("Two".into())));
|
|
let category = app.views.len() - 1;
|
|
app.views[0].loaded = true;
|
|
for index in [1, category] {
|
|
app.view_index = index;
|
|
finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true);
|
|
}
|
|
app.view_index = 1;
|
|
app.views[1].prefetch_requested = true;
|
|
app.views[1].feed_requests = app.views[1].feed_cursor.plan(1).unwrap();
|
|
let cancelled = app.views[1].feed_requests.clone();
|
|
app.switch_view(category);
|
|
finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true);
|
|
assert_eq!(
|
|
app.views[category].feed_cursor.plan(1).unwrap()[0].start,
|
|
PAGE_SIZE * 2 + 1
|
|
);
|
|
app.back_view();
|
|
assert_eq!(app.view_index, 1);
|
|
assert_eq!(app.views[1].feed_requests, cancelled);
|
|
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), cancelled);
|
|
assert!(app.views[1].prefetch_requested);
|
|
app.forward_view();
|
|
assert_eq!(app.view_index, category);
|
|
assert!(!app.views[category].prefetch_requested);
|
|
assert_eq!(
|
|
app.views[category].feed_cursor.plan(1).unwrap()[0].start,
|
|
PAGE_SIZE * 2 + 1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn category_membership_reordering_keeps_cursor_request_intent_and_cache() {
|
|
let mut app = App::new(
|
|
Options::parse_from(["tubular"]),
|
|
PathBuf::new(),
|
|
config::Config {
|
|
categories: [(
|
|
"Creators".into(),
|
|
vec![
|
|
"https://www.youtube.com/@two".into(),
|
|
"https://www.youtube.com/@one".into(),
|
|
],
|
|
)]
|
|
.into(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
app.view_index = 2;
|
|
finish_feed_fixture(
|
|
&mut app,
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#,
|
|
true,
|
|
);
|
|
app.views[2].query = " draft robotics ".into();
|
|
app.views[2].submitted_query = "robotics".into();
|
|
app.remember_description("jNQXAC9IVRw".into(), "Retained".into());
|
|
app.views[2].feed_requests = app.views[2].feed_cursor.plan(2).unwrap();
|
|
app.views[2].prefetch_requested = true;
|
|
let requests = app.views[2].feed_requests.clone();
|
|
let mut categories = app.config.categories.clone();
|
|
categories.get_mut("Creators").unwrap().reverse();
|
|
app.apply_config(config::Config {
|
|
categories,
|
|
..Default::default()
|
|
});
|
|
assert_eq!(app.views[2].feed_requests, requests);
|
|
assert_eq!(app.views[2].feed_cursor.plan(2).unwrap(), requests);
|
|
assert!(app.views[2].prefetch_requested);
|
|
assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw");
|
|
assert_eq!(app.views[2].selection.selected(), Some(0));
|
|
assert_eq!(app.views[2].query, " draft robotics ");
|
|
assert_eq!(app.views[2].submitted_query, "robotics");
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "jNQXAC9IVRw"),
|
|
Some("Retained")
|
|
);
|
|
let mut categories = app.config.categories.clone();
|
|
categories
|
|
.get_mut("Creators")
|
|
.unwrap()
|
|
.push("https://www.youtube.com/@three".into());
|
|
app.apply_config(config::Config {
|
|
categories,
|
|
..Default::default()
|
|
});
|
|
assert!(!app.views[2].loaded);
|
|
assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw");
|
|
assert!(app.views[2].feed_requests.is_empty());
|
|
assert!(!app.views[2].prefetch_requested);
|
|
assert_eq!(app.views[2].next_start, 1);
|
|
assert_eq!(app.views[2].query, " draft robotics ");
|
|
assert_eq!(app.views[2].submitted_query, "robotics");
|
|
assert!(
|
|
app.views[2]
|
|
.feed_cursor
|
|
.plan(3)
|
|
.unwrap()
|
|
.iter()
|
|
.all(|request| request.start == 1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn page_failures_and_view_switches_preserve_the_successful_cursor_until_reset() {
|
|
let mut app = app(&["tubular", "rust"]);
|
|
app.views[0].submitted_query = "rust".into();
|
|
app.finish_page(Err("First request failed".into()));
|
|
assert_eq!(app.views[0].next_start, 1);
|
|
assert!(!app.views[0].loaded);
|
|
assert!(app.views[0].page_error.is_some());
|
|
app.switch_view(0);
|
|
app.update();
|
|
assert!(app.job.is_none());
|
|
app.views[0].reset();
|
|
let entries: Vec<_> = (0..PAGE_SIZE)
|
|
.map(|index| {
|
|
serde_json::json!({
|
|
"id": format!("search{index:05}"),
|
|
"title": "Result",
|
|
})
|
|
})
|
|
.collect();
|
|
app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string()));
|
|
assert_eq!(app.views[0].next_start, PAGE_SIZE + 1);
|
|
app.views[0].selection.select(Some(17));
|
|
app.remember_description(app.views[0].videos[17].id.clone(), "Cached.".into());
|
|
app.switch_view(1);
|
|
app.switch_view(0);
|
|
assert_eq!(app.views[0].next_start, PAGE_SIZE + 1);
|
|
assert_eq!(app.views[0].selection.selected(), Some(17));
|
|
app.finish_page(Ok("not JSON".into()));
|
|
app.update();
|
|
assert!(app.job.is_none());
|
|
assert_eq!(app.views[0].videos.len(), PAGE_SIZE);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, &app.views[0].videos[17].id),
|
|
Some("Cached.")
|
|
);
|
|
assert_eq!(app.views[0].next_start, PAGE_SIZE + 1);
|
|
assert!(!app.views[0].needs_page());
|
|
let view = &mut app.views[0];
|
|
view.feed_requests = view.feed_cursor.plan(1).unwrap();
|
|
view.prefetch_requested = true;
|
|
view.reset();
|
|
assert!(view.feed_requests.is_empty());
|
|
assert!(!view.feed_cursor.has_more());
|
|
assert!(!view.prefetch_requested);
|
|
assert_eq!(view.next_start, 1);
|
|
assert!(!view.loaded);
|
|
assert!(!view.has_more);
|
|
assert!(view.page_error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn prefetch_only_runs_near_the_end_in_an_unobstructed_normal_view() {
|
|
let mut app = app(&["tubular"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
let mut page =
|
|
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Result"}]}"#).unwrap();
|
|
page.has_more = true;
|
|
app.views[0].accept_page(page, false).unwrap();
|
|
assert!(!app.can_prefetch());
|
|
app.mode = Mode::Normal;
|
|
assert!(!app.can_prefetch());
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
assert!(app.can_prefetch());
|
|
app.preview = Some(PreviewMode::Description);
|
|
assert!(!app.can_prefetch());
|
|
app.preview = Some(PreviewMode::Info);
|
|
assert!(!app.can_prefetch());
|
|
app.preview = None;
|
|
app.category_dialog = Some(CategoryDialog {
|
|
name: None,
|
|
..CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
channel: String::new(),
|
|
handle: String::new(),
|
|
},
|
|
String::new(),
|
|
BTreeMap::new(),
|
|
)
|
|
});
|
|
assert!(!app.can_prefetch());
|
|
app.category_dialog = None;
|
|
let entries: Vec<_> = (0..PAGE_SIZE)
|
|
.map(|index| {
|
|
serde_json::json!({
|
|
"id": format!("search{index:05}"),
|
|
"title": "Next result",
|
|
})
|
|
})
|
|
.collect();
|
|
app.views[0].reset();
|
|
app.views[0]
|
|
.accept_page(
|
|
youtube::search_results(&serde_json::json!({"entries": entries}).to_string()).unwrap(),
|
|
false,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
assert!(!app.can_prefetch());
|
|
let threshold = app.views[0].videos.len() - PAGE_SIZE / 3;
|
|
app.views[0].selection.select(Some(threshold - 1));
|
|
assert!(!app.can_prefetch());
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
assert!(app.can_prefetch());
|
|
assert!(app.job.is_none());
|
|
assert_eq!(app.views[0].selection.selected(), Some(threshold));
|
|
app.views[0].loaded = false;
|
|
assert!(!app.can_prefetch());
|
|
app.views[0].loaded = true;
|
|
app.views[0].has_more = false;
|
|
assert!(!app.can_prefetch());
|
|
app.views[0].has_more = true;
|
|
app.help_popup = Some(HelpPopup::default());
|
|
assert!(!app.can_prefetch());
|
|
app.help_popup = None;
|
|
app.views[0].selection.select(None);
|
|
assert!(!app.can_prefetch());
|
|
app.views[0].selection.select(Some(threshold));
|
|
app.views[0].page_error = Some("Stop retrying".into());
|
|
assert!(!app.can_prefetch());
|
|
app.views[0].page_error = None;
|
|
app.views[0].videos.clear();
|
|
app.views[0].selection.select(None);
|
|
assert!(app.can_prefetch());
|
|
}
|
|
|
|
#[test]
|
|
fn input_cursor_tracks_display_width_and_stays_inside_scrolled_fields() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
let mut terminal = Terminal::new(TestBackend::new(24, 20)).unwrap();
|
|
for (text, x) in [
|
|
("", 1),
|
|
("ab", 3),
|
|
("日本語", 7),
|
|
("e\u{301}", 2),
|
|
("abcdefghijklmnopqrstuvwxyz", 22),
|
|
] {
|
|
app.views[0].query = text.into();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert_eq!(terminal.get_cursor_position().unwrap(), (x, 2).into());
|
|
}
|
|
app.category_dialog = Some(CategoryDialog {
|
|
name: Some(String::new()),
|
|
..CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
channel: String::new(),
|
|
handle: String::new(),
|
|
},
|
|
String::new(),
|
|
BTreeMap::new(),
|
|
)
|
|
});
|
|
for (text, x) in [("", 3), ("日本語", 9), ("abcdefghijklmnopqrstuvwxyz", 20)] {
|
|
app.category_dialog.as_mut().unwrap().name = Some(text.into());
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert_eq!(terminal.get_cursor_position().unwrap(), (x, 7).into());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() {
|
|
for tag_input in [false, true] {
|
|
for (input, code, modifiers, expected) in [
|
|
(
|
|
"first 日本語 ",
|
|
KeyCode::Backspace,
|
|
KeyModifiers::ALT,
|
|
"first ",
|
|
),
|
|
(
|
|
"first\u{3000}cafe\u{301} ",
|
|
KeyCode::Char('w'),
|
|
KeyModifiers::CONTROL,
|
|
"first\u{3000}",
|
|
),
|
|
("word", KeyCode::Backspace, KeyModifiers::ALT, ""),
|
|
(" \u{3000} ", KeyCode::Backspace, KeyModifiers::ALT, ""),
|
|
("", KeyCode::Backspace, KeyModifiers::ALT, ""),
|
|
("first 日本語", KeyCode::Backspace, KeyModifiers::SUPER, ""),
|
|
(
|
|
"first 日本語",
|
|
KeyCode::Backspace,
|
|
KeyModifiers::SUPER | KeyModifiers::ALT,
|
|
"",
|
|
),
|
|
(
|
|
"first 日本語",
|
|
KeyCode::Char('u'),
|
|
KeyModifiers::CONTROL,
|
|
"",
|
|
),
|
|
(
|
|
"first 日本語",
|
|
KeyCode::Backspace,
|
|
KeyModifiers::NONE,
|
|
"first 日本",
|
|
),
|
|
] {
|
|
let mut app = app(&["tubular"]);
|
|
if tag_input {
|
|
app.mode = Mode::Normal;
|
|
app.category_dialog = Some(CategoryDialog {
|
|
name: Some(input.into()),
|
|
..CategoryDialog::new(
|
|
CategoryTarget::Channel {
|
|
channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"
|
|
.into(),
|
|
handle: "@jawed".into(),
|
|
},
|
|
"jawed".into(),
|
|
BTreeMap::new(),
|
|
)
|
|
});
|
|
} else {
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.views[0].query = input.into();
|
|
}
|
|
app.key(KeyEvent::new(code, modifiers));
|
|
let edited = app
|
|
.category_dialog
|
|
.as_ref()
|
|
.and_then(|dialog| dialog.name.as_deref())
|
|
.unwrap_or(&app.views[0].query);
|
|
assert_eq!(
|
|
edited, expected,
|
|
"{tag_input}: {input:?} {code:?} {modifiers:?}"
|
|
);
|
|
assert!(app.job.is_none());
|
|
assert_eq!(
|
|
app.mode,
|
|
if tag_input {
|
|
Mode::Normal
|
|
} else {
|
|
Mode::Insert
|
|
}
|
|
);
|
|
}
|
|
}
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].query = "keep this query".into();
|
|
app.mode = Mode::Normal;
|
|
for (code, modifiers) in [
|
|
(KeyCode::Backspace, KeyModifiers::ALT),
|
|
(KeyCode::Backspace, KeyModifiers::SUPER),
|
|
(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
|
] {
|
|
app.key(KeyEvent::new(code, modifiers));
|
|
assert_eq!(app.views[0].query, "keep this query");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
|
|
let mut app = app(&["tubular"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.views[0].videos = vec![
|
|
Video {
|
|
id: "jNQXAC9IVRw".into(),
|
|
title: "Me at the zoo".into(),
|
|
channel: None,
|
|
channel_id: None,
|
|
uploader_id: None,
|
|
timestamp: None,
|
|
duration: None,
|
|
publication_date: None,
|
|
availability: None,
|
|
},
|
|
Video {
|
|
id: "rQ_J9WH6CGk".into(),
|
|
title: "Rust course".into(),
|
|
channel: None,
|
|
channel_id: None,
|
|
uploader_id: None,
|
|
timestamp: None,
|
|
duration: None,
|
|
publication_date: None,
|
|
availability: None,
|
|
},
|
|
];
|
|
app.views[0].selection.select(Some(0));
|
|
|
|
for c in ['j', 'k', 'q'] {
|
|
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
|
|
}
|
|
app.update();
|
|
assert_eq!(app.views[0].query, "jkq");
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
assert!(app.job.is_none());
|
|
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
assert!(app.job.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
assert_eq!(app.mode, Mode::Insert);
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
assert!(app.views[0].query.is_empty());
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn category_dialog_is_modal_and_views_keep_independent_selections() {
|
|
let mut app = app(&["tubular", "rust"]);
|
|
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
|
|
app.views[0].videos = youtube::search_results(videos).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(1));
|
|
// The selected channel is unavailable; do not save a guessed display-name identity.
|
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_none());
|
|
app.views[0].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
|
|
for c in ['j', 'k', 'q', 'i', 't', 'd', ' '] {
|
|
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
|
|
}
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().name.as_deref(),
|
|
Some("jkqitd ")
|
|
);
|
|
assert_eq!(app.views[0].query, "rust");
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["A"]);
|
|
assert!(app.config.categories.is_empty());
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
assert!(!app.category_dialog.as_ref().unwrap().tags["A"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(!app.category_dialog.as_ref().unwrap().tags["A"]);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_none());
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
assert!(app.config.categories.is_empty());
|
|
|
|
let rust = vec!["https://www.youtube.com/@RustVideos".into()];
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
categories: [("Rust".into(), rust.clone())].into(),
|
|
channels: BTreeMap::new(),
|
|
pinned_categories: vec!["Rust".into()],
|
|
});
|
|
app.views[0].selection.select(Some(1));
|
|
app.views[1].videos = feed_fixture(videos).unwrap().videos;
|
|
app.views[1].loaded = true;
|
|
app.views[1].selection.select(Some(0));
|
|
app.views[2].videos = feed_fixture(videos).unwrap().videos;
|
|
app.views[2].loaded = true;
|
|
app.views[2].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 1);
|
|
assert_eq!(app.views[1].name, "Feed");
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 2);
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
assert_eq!(app.views[2].selection.selected(), Some(1));
|
|
assert_eq!(app.views[1].selection.selected(), Some(0));
|
|
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
|
|
assert_eq!(app.view_index, 1);
|
|
assert_eq!(app.views[1].selection.selected(), Some(0));
|
|
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
categories: [
|
|
(
|
|
"Books".into(),
|
|
vec!["https://www.youtube.com/@example".into()],
|
|
),
|
|
("Rust".into(), rust),
|
|
]
|
|
.into(),
|
|
channels: BTreeMap::new(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 3);
|
|
assert_eq!(app.views[3].name, "Rust");
|
|
assert_eq!(app.views[3].selection.selected(), Some(1));
|
|
assert!(app.views[3].loaded);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
categories: [("Rust".into(), vec![])].into(),
|
|
channels: BTreeMap::new(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.views[app.view_index].name, "Rust");
|
|
assert!(!app.views[app.view_index].loaded);
|
|
assert!(app.job.is_none());
|
|
app.load_page();
|
|
assert!(app.views[app.view_index].videos.is_empty());
|
|
assert!(app.views[app.view_index].selection.selected().is_none());
|
|
assert!(app.views[app.view_index].loaded);
|
|
}
|
|
|
|
#[test]
|
|
fn feed_cache_tracks_subscriptions_not_tag_memberships() {
|
|
let registered = "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA";
|
|
let direct = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
|
|
let added = "https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ";
|
|
let mut app = App::new(
|
|
Options::parse_from(["tubular", "rust"]),
|
|
PathBuf::new(),
|
|
config::Config {
|
|
keys: Default::default(),
|
|
channels: [("@RustVideos".into(), registered.into())].into(),
|
|
categories: [
|
|
("Direct".into(), vec![direct.into()]),
|
|
("Tagged".into(), vec![registered.into()]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
},
|
|
);
|
|
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#;
|
|
assert_eq!(app.feed_channels, vec![direct, registered]);
|
|
app.views[1].videos = feed_fixture(videos).unwrap().videos;
|
|
app.views[1].selection.select(Some(1));
|
|
app.views[1].loaded = true;
|
|
app.views[1].next_start = 41;
|
|
app.views[1].has_more = true;
|
|
app.views[1].feed_requests = app.views[1].feed_cursor.plan(2).unwrap();
|
|
app.views[1].prefetch_requested = true;
|
|
app.views[1].page_error = Some("Retained page error".into());
|
|
app.remember_description(
|
|
app.views[1].videos[1].id.clone(),
|
|
"Retained description".into(),
|
|
);
|
|
app.views[3].videos = feed_fixture(videos).unwrap().videos;
|
|
app.views[3].selection.select(Some(0));
|
|
app.views[3].loaded = true;
|
|
app.views[3].next_start = 61;
|
|
app.views[3].has_more = true;
|
|
app.views[3].feed_requests = app.views[3].feed_cursor.plan(1).unwrap();
|
|
app.views[3].prefetch_requested = true;
|
|
app.views[3].page_error = Some("Tag page failed".into());
|
|
app.switch_view(1);
|
|
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Direct".into(), vec![direct.into()]),
|
|
("Renamed".into(), vec![registered.into()]),
|
|
("Tagged".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 1);
|
|
assert!(app.views[1].loaded);
|
|
assert_eq!(app.views[1].videos[1].id, "rQ_J9WH6CGk");
|
|
assert_eq!(app.views[1].selection.selected(), Some(1));
|
|
assert_eq!(app.views[1].next_start, 41);
|
|
assert!(app.views[1].has_more);
|
|
assert_eq!(app.views[1].feed_requests.len(), 2);
|
|
assert!(app.views[1].prefetch_requested);
|
|
assert_eq!(
|
|
app.views[1].page_error.as_deref(),
|
|
Some("Retained page error")
|
|
);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, &app.views[1].videos[1].id),
|
|
Some("Retained description")
|
|
);
|
|
assert!(!app.views[4].loaded);
|
|
assert_eq!(app.views[4].next_start, 1);
|
|
assert!(!app.views[4].has_more);
|
|
assert!(app.views[4].feed_requests.is_empty());
|
|
assert!(!app.views[4].prefetch_requested);
|
|
assert!(app.views[4].page_error.is_none());
|
|
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Direct".into(), vec![direct.into()]),
|
|
("Renamed".into(), vec![]),
|
|
("Tagged".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.feed_channels, vec![direct, registered]);
|
|
assert!(app.views[1].loaded);
|
|
assert_eq!(app.views[1].videos.len(), 2);
|
|
assert_eq!(app.views[1].selection.selected(), Some(1));
|
|
assert_eq!(app.views[1].next_start, 41);
|
|
assert!(app.views[1].has_more);
|
|
assert_eq!(app.views[1].feed_requests.len(), 2);
|
|
assert!(app.views[1].prefetch_requested);
|
|
assert!(app.views[1].page_error.is_some());
|
|
|
|
let mut channels = app.config.channels.clone();
|
|
channels.insert("@YouTube".into(), added.into());
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels,
|
|
categories: app.config.categories.clone(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.feed_channels, vec![direct, added, registered]);
|
|
assert!(!app.views[1].loaded);
|
|
assert_eq!(app.views[1].next_start, 1);
|
|
assert!(!app.views[1].has_more);
|
|
assert!(app.views[1].feed_requests.is_empty());
|
|
assert!(!app.views[1].prefetch_requested);
|
|
assert!(app.views[1].page_error.is_none());
|
|
assert_eq!(app.view_index, 1);
|
|
assert!(app.job.is_none());
|
|
|
|
app.views[1].videos = feed_fixture(videos).unwrap().videos;
|
|
app.views[1].selection.select(Some(1));
|
|
app.views[1].loaded = true;
|
|
let mut channels = app.config.channels.clone();
|
|
channels.remove("@YouTube");
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels,
|
|
categories: app.config.categories.clone(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.feed_channels, vec![direct, registered]);
|
|
assert!(!app.views[1].loaded);
|
|
assert_eq!(app.view_index, 1);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn builtins_and_same_named_tags_keep_distinct_identities() {
|
|
let mut app = app(&["tubular", "rust"]);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: [(
|
|
"@RustVideos".into(),
|
|
"https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(),
|
|
)]
|
|
.into(),
|
|
categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
|
|
for (index, view) in app.views.iter_mut().enumerate() {
|
|
view.videos = feed_fixture(videos).unwrap().videos;
|
|
view.loaded = true;
|
|
view.selection.select(Some(usize::from(index % 2 == 0)));
|
|
}
|
|
app.switch_view(1);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 1);
|
|
assert_eq!(app.views[1].selection.selected(), Some(0));
|
|
assert_eq!(app.views[3].name, "Feed");
|
|
assert_eq!(app.views[3].selection.selected(), Some(1));
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
assert_eq!(app.views[4].selection.selected(), Some(0));
|
|
|
|
app.switch_view(0);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 0);
|
|
app.switch_view(2);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 3);
|
|
assert_eq!(app.views[3].selection.selected(), Some(1));
|
|
|
|
app.switch_view(4);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Beta".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 5);
|
|
assert_eq!(app.views[5].selection.selected(), Some(0));
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Beta".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
]
|
|
.into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.switch_view(4);
|
|
app.apply_config(config::Config {
|
|
keys: Default::default(),
|
|
channels: app.config.channels.clone(),
|
|
categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(),
|
|
pinned_categories: Vec::new(),
|
|
});
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views[1].name, "Feed");
|
|
assert_eq!(app.views[1].selection.selected(), Some(0));
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_feed_is_informative_and_navigation_wraps_without_extraction() {
|
|
let mut app = app(&["tubular"]);
|
|
assert_eq!(app.views.len(), 2);
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL));
|
|
assert_eq!(app.view_index, 1);
|
|
assert!(app.views[1].loaded);
|
|
assert!(app.views[1].videos.is_empty());
|
|
assert_eq!(app.views[1].selection.selected(), None);
|
|
assert!(app.job.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 0);
|
|
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
|
|
assert_eq!(app.views[app.view_index].source, ViewSource::Feed);
|
|
assert!(app.job.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_tags_stage_memberships_and_commit_only_from_the_list() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"tubular-tag-editor-test-{}.toml",
|
|
std::process::id()
|
|
));
|
|
struct TestFile(PathBuf);
|
|
impl Drop for TestFile {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_file(&self.0);
|
|
}
|
|
}
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&path)
|
|
.unwrap();
|
|
let _file = TestFile(path.clone());
|
|
let original = "[categories]\nAlias = [\"@RustVideos\"]\nCanonical = [\"UCaYhcUwRBNscFNUKTjgPFiA\"]\nEmpty = []\n";
|
|
std::fs::write(&path, original).unwrap();
|
|
let mut app = app(&["tubular", "rust"]);
|
|
app.config_path = path.clone();
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"}]}"#,
|
|
)
|
|
.unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().tags,
|
|
[
|
|
("Alias".into(), true),
|
|
("Canonical".into(), true),
|
|
("Empty".into(), false),
|
|
]
|
|
.into()
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["Alias"]);
|
|
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
for c in "Empty".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
|
|
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
|
for c in " Added ".chars() {
|
|
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
|
|
}
|
|
app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert_eq!(
|
|
app.category_dialog.as_ref().unwrap().selection.selected(),
|
|
Some(0)
|
|
);
|
|
assert!(app.category_dialog.as_ref().unwrap().tags["Added"]);
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
|
app.category_dialog.as_mut().unwrap().error = Some("Previous save failed".into());
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
assert!(app.category_dialog.as_ref().unwrap().error.is_none());
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_none());
|
|
let saved = config::load(&path).unwrap();
|
|
let canonical = vec!["https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".to_owned()];
|
|
assert!(saved.categories["Alias"].is_empty());
|
|
assert!(saved.categories["Added"].is_empty());
|
|
assert_eq!(saved.categories["Canonical"], canonical);
|
|
assert_eq!(saved.categories["Empty"], canonical);
|
|
let committed = std::fs::read_to_string(&path).unwrap();
|
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), committed);
|
|
std::fs::write(&path, "[categories]\n").unwrap();
|
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
|
assert!(
|
|
app.category_dialog
|
|
.as_ref()
|
|
.unwrap()
|
|
.selection
|
|
.selected()
|
|
.is_none()
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_none());
|
|
assert!(config::load(&path).unwrap().categories.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn preview_cycles_without_reloading_and_selection_resets_scrolling() {
|
|
use ratatui::{Terminal, backend::TestBackend};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"9bZkp7q19f0","title":"Second"}]}"#,
|
|
).unwrap().videos;
|
|
// Invalid fixture IDs keep thumbnail failure deterministic without invoking curl.
|
|
app.views[0].videos[0].id = "first".into();
|
|
app.views[0].videos[1].id = "second".into();
|
|
app.remember_description("first".into(), "First paragraph\n\n".repeat(40));
|
|
app.remember_description("second".into(), "Second paragraph\n\n".repeat(40));
|
|
app.views[0].selection.select(Some(0));
|
|
app.views[0].loaded = true;
|
|
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
|
|
let press = |app: &mut App, code| {
|
|
app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
|
};
|
|
press(&mut app, KeyCode::Char('t'));
|
|
assert_eq!(app.preview, None);
|
|
press(&mut app, KeyCode::Char('d'));
|
|
assert_eq!(app.preview, Some(PreviewMode::Info));
|
|
assert!(app.description_job.is_none());
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let info = terminal.backend().buffer().clone();
|
|
press(&mut app, KeyCode::Char('d'));
|
|
assert_eq!(app.preview, Some(PreviewMode::Description));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
press(&mut app, KeyCode::PageDown);
|
|
assert!(app.description_scroll > 0);
|
|
let scroll = app.description_scroll;
|
|
press(&mut app, KeyCode::Char('d'));
|
|
assert_eq!(app.preview, Some(PreviewMode::Info));
|
|
assert_eq!(app.description_scroll, scroll);
|
|
assert!(app.description_job.is_none());
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
// Includes the failed thumbnail text: cancellation would remove that image state.
|
|
assert_eq!(terminal.backend().buffer(), &info);
|
|
press(&mut app, KeyCode::Char('d'));
|
|
press(&mut app, KeyCode::Char('j'));
|
|
assert_eq!(app.preview, Some(PreviewMode::Description));
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
assert_eq!(app.description_scroll, 0);
|
|
assert_eq!(app.description_viewport, None);
|
|
assert!(app.description_error.is_none());
|
|
assert!(app.description_job.is_none());
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
let popup = preview_area(buffer.area);
|
|
let title = &buffer[(popup.x + 2, popup.y + 2)];
|
|
assert_eq!(title.symbol(), "S");
|
|
assert!(title.modifier.contains(ratatui::style::Modifier::BOLD));
|
|
press(&mut app, KeyCode::Down);
|
|
assert_eq!(app.description_scroll, 1);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.description_scroll, 2);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.description_scroll, 1);
|
|
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
|
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.description_scroll, 0);
|
|
app.description_scroll = app.description_max_scroll;
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
|
|
assert_eq!(app.description_scroll, app.description_max_scroll);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
press(&mut app, KeyCode::Char('d'));
|
|
press(&mut app, KeyCode::Up);
|
|
assert_eq!(app.views[0].selection.selected(), Some(0));
|
|
assert_eq!(app.description_scroll, 0);
|
|
press(&mut app, KeyCode::Esc);
|
|
assert_eq!(app.preview, None);
|
|
assert_eq!(
|
|
cached_description(&app.descriptions, "first"),
|
|
Some("First paragraph\n\n".repeat(40).as_str())
|
|
);
|
|
press(&mut app, KeyCode::Char('i'));
|
|
press(&mut app, KeyCode::Char('t'));
|
|
press(&mut app, KeyCode::Char('d'));
|
|
assert_eq!(app.views[0].query, "td");
|
|
assert_eq!(app.preview, None);
|
|
}
|
|
|
|
#[test]
|
|
fn full_description_scrolls_padded_metadata_and_body_together() {
|
|
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
|
|
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Selected video","channel":"Creator","upload_date":"20050423"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].selection.select(Some(0));
|
|
app.preview = Some(PreviewMode::Description);
|
|
app.remember_description(
|
|
"jNQXAC9IVRw".into(),
|
|
(0..100).map(|i| format!("Body line {i:03}\n")).collect(),
|
|
);
|
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let before = terminal.backend().buffer().clone();
|
|
let popup = preview_area(before.area);
|
|
let x = popup.x + 2;
|
|
let title_y = popup.y + 2;
|
|
let body_y = (title_y..popup.bottom() - 1)
|
|
.find(|&y| before[(x, y)].symbol() == "B")
|
|
.expect("description is visible below metadata");
|
|
for x in popup.x + 1..popup.right() - 1 {
|
|
assert_eq!(before[(x, popup.y + 1)].symbol(), " ");
|
|
}
|
|
assert!(before[(x, title_y)].modifier.contains(Modifier::BOLD));
|
|
assert!(before[(x, title_y + 2)].modifier.contains(Modifier::DIM));
|
|
assert!(!before[(x, body_y)].modifier.contains(Modifier::DIM));
|
|
assert_eq!(
|
|
app.description_viewport,
|
|
Some((popup.width - 4, popup.height - 2))
|
|
);
|
|
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let after = terminal.backend().buffer();
|
|
assert_eq!(after[(x, title_y - 1)].symbol(), "S");
|
|
assert!(after[(x, title_y - 1)].modifier.contains(Modifier::BOLD));
|
|
assert_eq!(after[(x, title_y + 1)].symbol(), "C");
|
|
assert!(after[(x, title_y + 1)].modifier.contains(Modifier::DIM));
|
|
let row = |buffer: &ratatui::buffer::Buffer, y| -> String {
|
|
(x..popup.right() - 2)
|
|
.map(|x| buffer[(x, y)].symbol())
|
|
.collect()
|
|
};
|
|
assert_eq!(row(&before, body_y).trim_end(), "Body line 000");
|
|
assert_eq!(row(after, body_y).trim_end(), "Body line 001");
|
|
for y in popup.y + 1..popup.bottom() - 1 {
|
|
assert_eq!(after[(popup.x + 1, y)].symbol(), " ");
|
|
assert_eq!(after[(popup.right() - 2, y)].symbol(), " ");
|
|
}
|
|
// A wrapped title cannot prevent reaching the description in a small pane.
|
|
let mut narrow = Terminal::new(TestBackend::new(12, 8)).unwrap();
|
|
narrow
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
assert!(app.description_max_scroll > 0);
|
|
app.description_scroll = app.description_max_scroll;
|
|
narrow
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let narrow_popup = preview_area(narrow.backend().buffer().area);
|
|
let narrow_buffer = narrow.backend().buffer();
|
|
let tail: String = (narrow_popup.y + 1..narrow_popup.bottom() - 1)
|
|
.flat_map(|y| {
|
|
(narrow_popup.x + 2..narrow_popup.right() - 2)
|
|
.map(move |x| narrow_buffer[(x, y)].symbol())
|
|
})
|
|
.collect();
|
|
assert!(tail.contains("099"), "{tail}");
|
|
}
|
|
|
|
#[test]
|
|
fn search_without_a_handle_can_tag_a_registered_channel() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"tubular-missing-handle-{}.toml",
|
|
std::process::id()
|
|
));
|
|
let original =
|
|
"[channels]\n'@ch.1676' = 'UChAQQ9at2MDlXjvQX3vL3Yw'\n[categories]\nJapanese = []\n";
|
|
std::fs::write(&path, original).unwrap();
|
|
let mut app = App::new(
|
|
Options::parse_from(["tubular"]),
|
|
path.clone(),
|
|
config::load(&path).unwrap(),
|
|
);
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"nwxkg84mADI","title":"Hokkaido Vlog","channel":"敷嶋てとらch.","channel_id":"UChAQQ9at2MDlXjvQX3vL3Yw","uploader_id":null}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].loaded = true;
|
|
app.views[0].selection.select(Some(0));
|
|
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_some(), "{}", app.status);
|
|
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let saved = config::load(&path).unwrap();
|
|
assert_eq!(
|
|
saved.categories["Japanese"],
|
|
["https://www.youtube.com/channel/UChAQQ9at2MDlXjvQX3vL3Yw"]
|
|
);
|
|
std::fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn escape_discards_checks_without_consuming_view_history() {
|
|
let mut app = app(&["tubular"]);
|
|
app.views[0].loaded = true;
|
|
app.views[1].loaded = true;
|
|
app.switch_view(1);
|
|
app.views[1].checked.insert("jNQXAC9IVRw".into());
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(app.views[1].checked.is_empty());
|
|
assert_eq!(app.view_index, 1);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 1);
|
|
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
|
|
assert_eq!(app.view_index, 0);
|
|
}
|