533 lines
18 KiB
Rust
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");
|
|
}
|