Files
soulseek-rs-modded/soulseek-rs/src/ui/main_tui/tests/browse.rs
T

533 lines
18 KiB
Rust

//! 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<TalkativeSession>) -> 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<soulseek_rs::SharedDirectory>,
) {
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");
}