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::>(), ["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::>(), ["rQ_J9WH6CGk", "jNQXAC9IVRw"], ); view.reset(); assert!(view.checked.is_empty()); // Old rows remain during refresh, but checks made there cannot leak into replacement data. view.toggle_checked(); assert!(!view.checked.is_empty()); view.accept_page( youtube::search_results(r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Replacement"}]}"#) .unwrap(), false, ) .unwrap(); assert!(view.checked.is_empty()); assert_eq!(view.playback_videos()[0].title, "Replacement"); } #[test] fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() { let mut app = app(&["tubular"]); let videos = youtube::search_results( r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#, ).unwrap().videos; app.views[0].videos = videos.clone(); app.views[0].selection.select(Some(0)); app.views[0].loaded = true; app.views[1].loaded = true; app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); app.switch_view(1); assert!(app.views[1].checked.is_empty()); app.switch_view(0); assert!(app.views[0].checked.contains(&videos[0].id)); app.mode = Mode::Insert; app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert_eq!(app.views[0].checked.len(), 1); app.mode = Mode::Normal; for preview in [ None, Some(PreviewMode::Info), Some(PreviewMode::Description), ] { app.preview = preview; app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)); assert!(app.views[0].checked.is_empty()); app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert!(app.views[0].checked.contains(&videos[0].id)); } app.preview = None; for video in &videos { app.video_state .save(CollectionKind::WatchLater, video) .unwrap(); } app.browse_collection(CollectionKind::WatchLater); let index = app.view_index; app.views[index].selection.select(Some(0)); app.views[index].toggle_checked(); app.views[index].selection.select(Some(1)); app.views[index].toggle_checked(); let removed = app.views[index].videos[1].id.clone(); app.remove_collection(); assert!(!app.views[index].checked.contains(&removed)); assert_eq!(app.views[index].checked.len(), 1); assert_eq!(app.views[index].playback_videos().len(), 1); app.views[index].watch_filter = WatchFilter::Watched; app.rebuild_collections(); assert!(app.views[index].videos.is_empty()); assert!(app.views[index].checked.is_empty()); app.views[index].watch_filter = WatchFilter::All; app.rebuild_collections(); app.views[index].toggle_checked(); // Refresh is a replacement even for locally rebuilt collection views. app.load_view(); assert!(app.views[index].checked.is_empty()); assert_eq!(app.views[0].checked.len(), 1); // An empty category exercises query replacement entirely offline. app.config.categories.insert("Empty".into(), Vec::new()); let mut view = View::new("Empty".into(), ViewSource::Category("Empty".into())); view.videos = videos; view.selection.select(Some(0)); view.toggle_checked(); view.query = "replacement query".into(); app.views.push(view); app.view_index = app.views.len() - 1; app.search(); assert!(app.views[app.view_index].checked.is_empty()); assert!(app.job.is_none()); } #[test] fn playback_checks_render_independently_and_live_hints_use_configured_keys() { let mut app = app(&["tubular"]); let document = "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n" .parse::() .unwrap(); app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); app.key_hints = KeyHints::new(&app.config.keys); for context in [ Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, ] { for key in ["F20", "F21"] { assert!(app.key_hints.footer(context).contains(key)); assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key))); assert!(app.config.keys.help_text().to_string().contains(key)); } } app.views[0].videos = youtube::search_results( r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Checked"},{"id":"rQ_J9WH6CGk","title":"Unchecked"}]}"#, ).unwrap().videos; for (index, video) in app.views[0].videos.iter_mut().enumerate() { video.id = format!("offline:{index}"); } app.views[0].selection.select(Some(0)); app.key(KeyEvent::new(KeyCode::F(20), KeyModifiers::NONE)); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap(); terminal .draw(|frame| app.draw(frame, Duration::ZERO)) .unwrap(); let rendered: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); assert!(rendered.contains("1 checked")); app.key(KeyEvent::new(KeyCode::F(21), KeyModifiers::NONE)); assert!(app.views[0].checked.is_empty()); } #[test] fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() { let mut app = app(&["tubular"]); app.views[0].videos = youtube::search_results( r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#, ).unwrap().videos; app.views[0].selection.select(Some(1)); assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); app.views[0].toggle_checked(); app.views[0].selection.select(Some(0)); app.options.player = "/nonexistent/tubular-test-player".into(); app.play(); assert!(app.player_launch.is_none()); assert!(app.views[0].checked.contains("rQ_J9WH6CGk")); assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk"); app.options.player = "/usr/bin/true".into(); app.play(); assert!(app.player_launch.is_some()); assert!(app.views[0].checked.is_empty()); assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw"); app.player_launch = None; app.play(); assert!(app.player_launch.is_some()); } #[test] fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() { let mut app = app(&["tubular"]); 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::().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::() .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 { let channels = serde_json::Deserializer::from_str(output) .into_iter::() .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::() .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::>() .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::>(), ["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::>(), ["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::>(), ["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::() .unwrap(); assert_eq!( disk["categories"]["Saved union"] .as_array() .unwrap() .iter() .map(|value| value.as_str().unwrap()) .collect::>(), ["@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::().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::() .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::().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::>(), ["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::().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::>() }) .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::() .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::() .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::() .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| -> 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::>() }; 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::() .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::>(), ["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::(), 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::>() .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::(), 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::>(), ["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::>(), ["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); }