//! The browse popup, over a peer's shares and over our own. use super::*; fn directory( name: &str, files: &[(&str, u64)], ) -> soulseek_rs::SharedDirectory { soulseek_rs::SharedDirectory { name: name.to_string(), files: files .iter() .map(|(name, size)| soulseek_rs::SharedFileEntry { name: (*name).to_string(), size: *size, attributes: Vec::new(), }) .collect(), } } fn folder_window(session: Arc) -> MainTui { let mut tui = attach(session); tui.state.focused_pane = FocusedPane::Results; tui.state.results_items = results(2); tui.state.results_items[0].filename = "Music\\Artist\\Album\\03 - Track.flac".into(); tui.state.results_items[1].filename = "Other\\track.mp3".into(); tui.state.results_items[1].username = "alice".into(); tui.state.results_table_state.select(Some(0)); tui } fn wait_for(mut ready: impl FnMut() -> bool) { let deadline = std::time::Instant::now() + Duration::from_secs(5); while !ready() { assert!( std::time::Instant::now() < deadline, "worker did not finish" ); std::thread::sleep(Duration::from_millis(5)); } } fn finish_folder(tui: &mut MainTui) { wait_for(|| { tui.poll_session(); tui.folder_fetches.is_empty() && tui.folder_queue_results.is_empty() }); tui.update_downloads(); } fn answer( session: &TalkativeSession, directories: Vec, ) { wait_for(|| !session.browse_requests.lock().unwrap().is_empty()); session .browse_results .lock() .unwrap() .insert("bob".into(), directories); } #[test] fn containing_folder_uses_soulseek_paths_and_rejects_malformed_paths() { use super::super::browse::containing_folder; for (path, parent) in [ ( "Music\\Artist\\Album\\03 - Track.flac", "Music\\Artist\\Album", ), ("@@share\\song.mp3", "@@share"), ("\\Music\\Björk\\曲.flac", "\\Music\\Björk"), ("C:\\Music\\song.mp3", "C:\\Music"), ] { assert_eq!(containing_folder(path), Some(parent), "{path}"); } for path in [ "", "track.mp3", "\\track.mp3", "Music\\", "Music\\..\\song.mp3", "Music\\.\\song.mp3", "Music\\\\song.mp3", "Music\\bad\0.mp3", "Music/track.mp3", ] { assert_eq!(containing_folder(path), None, "{path:?}"); } } #[test] fn shift_d_downloads_only_highlighted_folder_from_complete_peer_listing() { for per_user in [false, true] { let (release, gate) = std::sync::mpsc::channel(); let session = Arc::new(TalkativeSession { accept_downloads: true, browse_gate: Some(std::sync::Mutex::new(gate)), ..Default::default() }); let mut tui = folder_window(session.clone()); tui.use_username_dirs = per_user; tui.state.results_selected_indices.insert(1); // The kept filter maps the highlighted row to the original result. tui.state.results_items.swap(0, 1); tui.state.results_filter_query = "Track.flac".into(); tui.apply_filter(); tui.state.results_selected_indices.clear(); tui.state.results_selected_indices.insert(0); tui.handle_key_event(KeyEvent::new( KeyCode::Char('D'), KeyModifiers::SHIFT, )); // The worker is held at the request: navigation and rendering still run. press(&mut tui, KeyCode::Tab); assert_ne!(tui.state.focused_pane, FocusedPane::Results); assert!(screen_of(&mut tui).contains("Fetching folder")); assert!(!tui.state.show_browse); assert!(tui.state.browse.tabs.is_empty()); assert!(session.download_requests.lock().unwrap().is_empty()); release.send(()).unwrap(); answer( &session, vec![ directory("Music\\Artist", &[("parent.mp3", 1)]), directory( "Music\\Artist\\Album\\", &[ ("03 - Track.flac", 30), ("01 - Missing.flac", 10), ("cover.jpg", 20), ], ), directory("Music\\Artist\\Album\\Disc 2", &[("child.flac", 2)]), directory( "Music\\Artist\\Album extras", &[("sibling.flac", 3)], ), directory("Other", &[("track.mp3", 4)]), ], ); finish_folder(&mut tui); let downloads = session.download_requests.lock().unwrap(); assert_eq!(downloads.len(), 3); for (download, name, size) in [ (&downloads[0], "03 - Track.flac", 30), (&downloads[1], "01 - Missing.flac", 10), (&downloads[2], "cover.jpg", 20), ] { assert_eq!(download.username, "bob"); assert_eq!( download.filename, format!("Music\\Artist\\Album\\{name}") ); assert_eq!(download.size, size); assert_eq!( download.download_directory, if per_user { "/tmp/bob" } else { "/tmp" } ); } assert_eq!(tui.state.downloads.len(), 3); assert_eq!(tui.state.results_selected_indices, [0].into()); assert!(screen_of(&mut tui).contains("Queued 3 files from bob")); } } #[test] fn enter_still_downloads_selected_files_and_b_still_opens_the_owner() { let session = Arc::new(TalkativeSession { accept_downloads: true, ..Default::default() }); let mut tui = folder_window(session.clone()); tui.state.results_selected_indices.insert(1); press(&mut tui, KeyCode::Enter); wait_for(|| !session.download_requests.lock().unwrap().is_empty()); let requests = session.download_requests.lock().unwrap(); assert_eq!(requests.len(), 1); assert_eq!(requests[0].username, "alice"); assert_eq!(requests[0].filename, "Other\\track.mp3"); assert!(tui.state.results_selected_indices.is_empty()); assert!(session.browse_requests.lock().unwrap().is_empty()); drop(requests); press(&mut tui, KeyCode::Char('b')); assert!(tui.state.show_browse); assert_eq!(tui.state.browse.active_tab().unwrap().username, "bob"); assert_eq!(*session.browse_requests.lock().unwrap(), ["bob"]); assert!(tui.folder_fetches.is_empty()); } #[test] fn d_without_a_highlight_or_outside_results_does_nothing() { let session = Arc::new(TalkativeSession::default()); let mut tui = folder_window(session.clone()); for selection in [None, Some(99)] { tui.state.results_table_state.select(selection); press(&mut tui, KeyCode::Char('D')); assert!(tui.folder_fetches.is_empty()); assert!(tui.folder_download_status.is_none()); } tui.state.results_items.clear(); tui.state.results_table_state.select(Some(0)); press(&mut tui, KeyCode::Char('D')); assert!(tui.folder_fetches.is_empty()); tui.state.results_items = results(1); for pane in [FocusedPane::Searches, FocusedPane::Downloads] { tui.state.focused_pane = pane; press(&mut tui, KeyCode::Char('D')); assert!(tui.folder_fetches.is_empty()); } assert!(session.browse_requests.lock().unwrap().is_empty()); } #[test] fn d_is_text_while_editing_a_filter_and_ctrl_d_still_pages() { let mut tui = results_tui(40); tui.state.results_table_state.select(Some(0)); ctrl(&mut tui, 'd'); assert!(selected(&tui).unwrap() > 0); press(&mut tui, KeyCode::Char('/')); press(&mut tui, KeyCode::Char('D')); assert_eq!(tui.state.results_filter_query, "D"); assert!(tui.folder_fetches.is_empty()); } #[test] fn failed_requests_and_timeouts_report_errors_and_allow_retry() { let session = Arc::new(TalkativeSession { browse_fails: true, ..Default::default() }); let mut tui = folder_window(session.clone()); press(&mut tui, KeyCode::Char('D')); finish_folder(&mut tui); assert!(screen_of(&mut tui).contains("Could not fetch folder from bob")); assert!(session.download_requests.lock().unwrap().is_empty()); let session = Arc::new(TalkativeSession::default()); let mut tui = folder_window(session.clone()); press(&mut tui, KeyCode::Char('D')); wait_for(|| !session.browse_requests.lock().unwrap().is_empty()); tui.folder_fetches[0].requested_at = std::time::Instant::now() .checked_sub(Duration::from_secs(21)) .expect("a time before the browse timeout"); finish_folder(&mut tui); assert!(screen_of(&mut tui).contains("timed out")); // A late response must not queue anything after the timeout. answer( &session, vec![directory("Music\\Artist\\Album", &[("late.flac", 1)])], ); tui.poll_session(); assert!(session.download_requests.lock().unwrap().is_empty()); press(&mut tui, KeyCode::Char('D')); wait_for(|| session.browse_requests.lock().unwrap().len() == 2); assert_eq!(tui.folder_fetches.len(), 1); } #[test] fn empty_missing_and_malformed_folders_do_not_queue_files() { for (listing, message) in [ (vec![directory("Music\\Artist\\Album", &[])], "is empty"), (vec![], "folder not found"), ( vec![directory( "Music\\Artist\\Album\\Child", &[("child.flac", 1)], )], "folder not found", ), ( vec![directory( "Music\\Artist\\Album", &[("valid.flac", 1), ("..\\escape.flac", 2)], )], "invalid file name", ), ] { let session = Arc::new(TalkativeSession { accept_downloads: true, ..Default::default() }); let mut tui = folder_window(session.clone()); press(&mut tui, KeyCode::Char('D')); answer(&session, listing); finish_folder(&mut tui); assert!( tui.folder_download_status .as_ref() .unwrap() .contains(message) ); assert!(session.download_requests.lock().unwrap().is_empty()); } let session = Arc::new(TalkativeSession::default()); let mut tui = folder_window(session.clone()); tui.state.results_items[0].filename = "track.flac".into(); press(&mut tui, KeyCode::Char('D')); assert!(screen_of(&mut tui).contains("invalid remote file path")); assert!(tui.folder_fetches.is_empty()); assert!(session.browse_requests.lock().unwrap().is_empty()); } #[test] fn download_queue_failures_are_reported() { let session = Arc::new(TalkativeSession::default()); let mut tui = folder_window(session.clone()); press(&mut tui, KeyCode::Char('D')); answer( &session, vec![directory( "Music\\Artist\\Album", &[("a.flac", 1), ("b.flac", 2)], )], ); finish_folder(&mut tui); assert!(screen_of(&mut tui).contains("Queued 0 files from bob; 2 failed")); assert!(tui.state.downloads.is_empty()); } #[test] fn a_listing_serves_both_browse_and_folder_download_without_duplicate_requests() { for browse_first in [false, true] { let session = Arc::new(TalkativeSession { accept_downloads: true, ..Default::default() }); let mut tui = folder_window(session.clone()); if browse_first { press(&mut tui, KeyCode::Char('b')); press(&mut tui, KeyCode::Esc); } press(&mut tui, KeyCode::Char('D')); press(&mut tui, KeyCode::Char('D')); assert_eq!(tui.folder_fetches.len(), 1); press(&mut tui, KeyCode::Char('b')); answer( &session, vec![directory("Music\\Artist\\Album", &[("a.flac", 1)])], ); finish_folder(&mut tui); assert_eq!(*session.browse_requests.lock().unwrap(), ["bob"]); assert_eq!( tui.state.browse.active_tab().unwrap().status, crate::models::BrowseStatus::Loaded ); assert_eq!(session.download_requests.lock().unwrap().len(), 1); } } #[test] fn help_documents_the_containing_folder_key() { let mut tui = results_tui(1); press(&mut tui, KeyCode::Char('?')); let screen = screen_sized(&mut tui, 180, 60); let row = row_with(&screen, "download highlighted file's containing folder"); assert!(row.contains('D')); } #[test] fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { let session = Arc::new(TalkativeSession::default()); *session.listing.lock().expect("not poisoned") = vec![soulseek_rs::SharedDirectory { name: "Music\\Album".to_string(), files: vec![soulseek_rs::SharedFileEntry { name: "track.mp3".to_string(), size: 4096, attributes: Vec::new(), }], }]; let mut tui = attach(session.clone()); press(&mut tui, KeyCode::Char('B')); assert!(tui.state.show_browse, "the popup opens"); let tab = tui.state.browse.active_tab().expect("tab"); assert!(tab.own, "on this session's own index"); assert_eq!(tab.username, "tester"); assert_eq!(tab.file_count, 1); let screen = screen_of(&mut tui); assert!(screen.contains("My shares"), "{screen}"); press(&mut tui, KeyCode::Char('L')); let screen = screen_of(&mut tui); assert!( screen.contains("Album") && screen.contains("track.mp3"), "the virtual path a peer would see, folder by folder: {screen}" ); // Nothing here is downloadable from ourselves. press(&mut tui, KeyCode::Char('d')); assert!(tui.state.downloads.is_empty()); // `r` re-scans the disk rather than re-asking a peer, and the tab // picks up what the fresh scan found. session.listing.lock().expect("not poisoned").clear(); press(&mut tui, KeyCode::Char('r')); assert!( session.shares_set.lock().expect("not poisoned").is_some(), "the session was asked to re-index" ); assert_eq!( tui.state.browse.active_tab().expect("tab").file_count, 0, "the view follows the new index" ); // A second B focuses the tab it already opened. press(&mut tui, KeyCode::Esc); press(&mut tui, KeyCode::Char('B')); assert_eq!(tui.state.browse.tabs.len(), 1); } #[test] fn o_leaves_the_share_view_for_the_folders_that_feed_it() { let mut tui = attach(Arc::new(TalkativeSession::default())); press(&mut tui, KeyCode::Char('B')); press(&mut tui, KeyCode::Char('o')); assert!(!tui.state.show_browse, "the popup steps aside"); assert!(tui.state.settings.is_some(), "for the settings pane"); } #[test] fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { let session = Arc::new(TalkativeSession::default()); let mut tui = attach(session); browsed(&mut tui, 40); let rows = tui.state.browse.active_tab().expect("tab").rows().len(); assert_eq!(rows, 41, "the folder and its files"); press(&mut tui, KeyCode::End); let selected = |tui: &MainTui| { tui.state.browse.active_tab().expect("tab").selected_row }; assert_eq!(selected(&tui), 40); press(&mut tui, KeyCode::Home); assert_eq!(selected(&tui), 0); press(&mut tui, KeyCode::PageDown); let page = selected(&tui); assert!(page > 1 && page < 40, "a page of rows: {page}"); ctrl(&mut tui, 'd'); assert!(selected(&tui) > page, "ctrl-d pages"); assert!( tui.state.downloads.is_empty() && tui.state.downloads_receiver_channel.is_none(), "and does not download the folder" ); press(&mut tui, KeyCode::Char('G')); assert_eq!(selected(&tui), 40); assert!(tui.state.show_browse, "still open"); } #[test] fn folder_keys_and_the_filter_move_through_a_browsed_tree() { let mut tui = attach(Arc::new(TalkativeSession::default())); tui.state.browse.open("bob"); let listing = vec![ soulseek_rs::SharedDirectory { name: "music\\alpha".to_string(), files: vec![ soulseek_rs::SharedFileEntry { name: "a1.mp3".to_string(), size: 1, attributes: Vec::new(), }, soulseek_rs::SharedFileEntry { name: "a2.flac".to_string(), size: 2, attributes: Vec::new(), }, ], }, soulseek_rs::SharedDirectory { name: "music\\beta".to_string(), files: vec![soulseek_rs::SharedFileEntry { name: "b1.mp3".to_string(), size: 3, attributes: Vec::new(), }], }, ]; tui.state .browse .active_tab_mut() .expect("tab") .load(&listing); tui.state.show_browse = true; let _ = screen_of(&mut tui); let selected = |tui: &MainTui| { tui.state.browse.active_tab().expect("tab").selected_row }; let rows = |tui: &MainTui| { tui.state.browse.active_tab().expect("tab").rows().len() }; press(&mut tui, KeyCode::Char('L')); assert_eq!(rows(&tui), 6, "every folder open"); press(&mut tui, KeyCode::Char('J')); press(&mut tui, KeyCode::Char('J')); assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); press(&mut tui, KeyCode::Char('K')); assert_eq!(selected(&tui), 1, "back to alpha"); press(&mut tui, KeyCode::Char('H')); assert_eq!(rows(&tui), 1, "just music"); press(&mut tui, KeyCode::Char('/')); for c in "flac".chars() { press(&mut tui, KeyCode::Char(c)); } assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); let screen = screen_of(&mut tui); assert!(screen.contains("filter: flac_"), "{screen}"); ctrl(&mut tui, 'd'); assert_eq!( tui.state.browse.active_tab().expect("tab").filter(), "flac", "a control chord pages, it does not type" ); press(&mut tui, KeyCode::Enter); press(&mut tui, KeyCode::Char('j')); press(&mut tui, KeyCode::Char('j')); assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); press(&mut tui, KeyCode::Esc); assert_eq!(rows(&tui), 1, "Esc clears the filter first"); assert!(tui.state.show_browse); press(&mut tui, KeyCode::Esc); assert!(!tui.state.show_browse, "then hides the popup"); }