Files
soulseek-rs-modded/soulseek-rs/src/ui/main_tui/tests/mod.rs
T
Maik Steiger 9805b807e5 fix(tui): removed a ton of use_username_dirs references
feat(tui): username is now appended to download_dir before passed as parameter
fix(tui): username is sanitized before appended to path
2026-10-09 13:33:22 +02:00

562 lines
17 KiB
Rust

//! The window driven the way a terminal drives it: keys and mouse events in,
//! a rendered screen out, over a stand-in session.
//!
//! This file is the harness the topic modules below share — the session
//! double, the window constructors, and the press/screen helpers.
mod browse;
mod layout;
mod navigation;
mod rooms;
mod searches;
mod session;
mod settings;
mod transfers;
use super::*;
use crate::daemon::proto::ChatMessageDto;
use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// A stand-in for a daemon: it already holds a conversation, a transfer
/// queue, and a search cache, none of which this window created.
#[derive(Default)]
struct TalkativeSession {
history: Vec<ChatMessageDto>,
downloads: Vec<soulseek_rs::types::Download>,
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
/// What `try_get_search_results` hands back to the window.
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
/// Every query a window put on the wire, in order.
searched: std::sync::Mutex<Vec<String>>,
/// How many times a window asked for the search list.
all_searches_calls: std::sync::atomic::AtomicUsize,
/// How many times a window pulled a full result set across. Against a
/// daemon each pull is the whole set over the socket, so the tests
/// count them.
result_fetches: std::sync::atomic::AtomicUsize,
/// Whether this session is a daemon's. Local sessions do not sync.
shared: bool,
/// What `set_download_directory` was last asked for, if anything.
download_dir_set: std::sync::Mutex<Option<String>>,
/// What `set_shared_directories` was last asked for, if anything.
shares_set: std::sync::Mutex<Option<Vec<String>>>,
/// What `change_password` was last asked for, if anything.
password_set: std::sync::Mutex<Option<String>>,
/// The share index this session serves to peers.
listing: std::sync::Mutex<Vec<soulseek_rs::SharedDirectory>>,
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
download_removed: std::sync::Mutex<Vec<(String, String)>>,
}
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
at_status(username, filename, soulseek_rs::DownloadStatus::Queued)
}
fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download {
at_status(username, filename, soulseek_rs::DownloadStatus::Completed)
}
fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo {
soulseek_rs::types::UploadInfo {
username: username.to_string(),
filename: filename.to_string(),
size: 10,
bytes_sent: 1,
speed_bytes_per_sec: 1.0,
status: soulseek_rs::types::UploadStatus::InProgress,
}
}
fn at_status(
username: &str,
filename: &str,
status: soulseek_rs::DownloadStatus,
) -> soulseek_rs::types::Download {
let (sender, _) = std::sync::mpsc::channel();
soulseek_rs::types::Download {
username: username.to_string(),
filename: filename.to_string(),
token: 1,
size: 4096,
download_directory: "/tmp".to_string(),
status,
sender,
queue_position: None,
metadata: soulseek_rs::types::DownloadMetadata::default(),
}
}
fn key(ch: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)
}
impl SessionApi for TalkativeSession {
fn message_history(&self) -> Vec<ChatMessageDto> {
self.history.clone()
}
fn daemon_endpoint(&self) -> Option<String> {
self.shared.then(|| "test-daemon".to_string())
}
fn username(&self) -> String {
"tester".to_string()
}
fn listen_port(&self) -> Option<u16> {
None
}
fn session_loss(&self) -> Option<soulseek_rs::SessionLoss> {
None
}
fn search_with_cancel(
&self,
query: &str,
_timeout: Duration,
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
self.searched
.lock()
.expect("not poisoned")
.push(query.to_string());
Ok(Vec::new())
}
fn get_search_results(&self, _key: &str) -> Vec<soulseek_rs::SearchResult> {
Vec::new()
}
fn all_searches(&self) -> Vec<crate::api::SessionSearch> {
self.all_searches_calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.searches.lock().expect("not poisoned").clone()
}
fn forget_search(&self, query: &str) -> bool {
let mut searches = self.searches.lock().expect("not poisoned");
let before = searches.len();
searches.retain(|search| search.query != query);
searches.len() != before
}
fn get_search_results_count(&self, _key: &str) -> usize {
0
}
fn try_get_search_results(
&self,
_key: &str,
) -> Option<Vec<soulseek_rs::SearchResult>> {
self.result_fetches
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Some(self.results.lock().expect("not poisoned").clone())
}
fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn wishlist_interval(&self) -> Duration {
Duration::from_mins(12)
}
fn download(
&self,
_filename: String,
_username: String,
_size: u64,
_directory: String,
) -> soulseek_rs::Result<(
soulseek_rs::types::Download,
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
)> {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
fn download_with_metadata(
&self,
_filename: String,
_username: String,
_size: u64,
_directory: String,
_metadata: soulseek_rs::types::DownloadMetadata,
) -> soulseek_rs::Result<(
soulseek_rs::types::Download,
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
)> {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
fn get_all_downloads(&self) -> Vec<soulseek_rs::types::Download> {
self.downloads.clone()
}
fn pause_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn resume_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn remove_queued_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn remove_download(&self, u: &str, f: &str) -> bool {
self.download_removed
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn uploads(&self) -> Vec<soulseek_rs::UploadInfo> {
Vec::new()
}
fn take_upload_events(&self) -> Vec<soulseek_rs::UploadInfo> {
Vec::new()
}
fn cancel_upload(&self, u: &str, f: &str) -> bool {
self.upload_cancelled
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn cancel_download(&self, u: &str, f: &str) -> bool {
self.download_cancelled
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn set_upload_slots(&self, _slots: usize) {}
fn check_privileges(&self) -> soulseek_rs::Result<()> {
Ok(())
}
fn own_privilege_seconds(&self) -> Option<u32> {
None
}
fn request_room_list(&self) -> soulseek_rs::Result<()> {
Ok(())
}
fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn say_in_room(
&self,
_room: &str,
_message: &str,
) -> soulseek_rs::Result<()> {
Ok(())
}
fn room_members(&self, _room: &str) -> Vec<String> {
Vec::new()
}
fn room_member_stats(
&self,
_room: &str,
) -> Vec<soulseek_rs::types::RoomUserStats> {
Vec::new()
}
fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> {
Vec::new()
}
fn send_private_message(
&self,
_u: &str,
_m: &str,
) -> soulseek_rs::Result<()> {
Ok(())
}
fn take_private_messages(&self) -> Vec<soulseek_rs::UserMessage> {
Vec::new()
}
fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn take_browse_result(
&self,
_username: &str,
) -> Option<Vec<soulseek_rs::SharedDirectory>> {
None
}
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn watched_users(&self) -> Vec<String> {
Vec::new()
}
fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> {
None
}
fn shared_counts(&self) -> (u32, u32) {
(3, 12)
}
fn change_password(&self, password: &str) -> soulseek_rs::Result<()> {
*self.password_set.lock().expect("not poisoned") =
Some(password.to_string());
Ok(())
}
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
self.listing.lock().expect("not poisoned").clone()
}
fn shared_directories(&self) -> Vec<String> {
Vec::new()
}
fn set_shared_directories(
&self,
directories: Vec<String>,
) -> soulseek_rs::Result<()> {
*self.shares_set.lock().expect("not poisoned") = Some(directories);
Ok(())
}
fn set_download_directory(
&self,
directory: String,
) -> soulseek_rs::Result<()> {
*self.download_dir_set.lock().expect("not poisoned") = Some(directory);
Ok(())
}
}
fn tui(history: Vec<ChatMessageDto>) -> MainTui {
with_session(TalkativeSession {
history,
shared: true,
..TalkativeSession::default()
})
}
fn shared(searches: Vec<crate::api::SessionSearch>) -> TalkativeSession {
TalkativeSession {
searches: std::sync::Mutex::new(searches),
shared: true,
..TalkativeSession::default()
}
}
fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch {
crate::api::SessionSearch {
query: query.to_string(),
files,
started_secs_ago: Some(age),
}
}
fn with_session(session: TalkativeSession) -> MainTui {
attach(Arc::new(session))
}
fn attach(session: Arc<TalkativeSession>) -> MainTui {
MainTui::new(
session,
"/tmp".to_string(),
false,
Duration::from_secs(1),
None,
None,
)
}
fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult {
soulseek_rs::SearchResult {
token: 0,
files: names
.iter()
.map(|name| soulseek_rs::types::File {
username: "bob".to_string(),
name: (*name).to_string(),
size: 42,
attribs: std::collections::HashMap::new(),
})
.collect(),
slots: 1,
speed: 0,
username: "bob".to_string(),
}
}
/// An attached window that selected a two-file search and has already
/// ticked past its one necessary fetch.
fn window_that_pulled_two_files() -> (Arc<TalkativeSession>, MainTui) {
let session = Arc::new(TalkativeSession {
results: std::sync::Mutex::new(vec![files_from_bob(&[
"@@x\\a.mp3",
"@@x\\b.mp3",
])]),
..shared(vec![search_of("aphex twin", 2, 9_999)])
});
let mut tui = attach(session.clone());
tui.state.selected_search_index = Some(0);
tui.update_search_results();
tui.update_search_results();
(session, tui)
}
fn results(n: usize) -> Vec<crate::models::FileDisplayData> {
(0..n)
.map(|i| crate::models::FileDisplayData {
filename: format!("@@x\\Music\\{i:02}.mp3"),
username: "bob".to_string(),
..Default::default()
})
.collect()
}
fn results_tui(n: usize) -> MainTui {
let mut tui = with_session(TalkativeSession::default());
tui.state.results_items = results(n);
tui.state.focused_pane = FocusedPane::Results;
// 88 cells wide gives the name column 11 cells and the folder 7.
tui.state.results_pane_area =
Some(ratatui::layout::Rect::new(0, 0, 88, 13));
tui
}
fn press(tui: &mut MainTui, code: KeyCode) {
tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
fn ctrl(tui: &mut MainTui, ch: char) {
tui.handle_key_event(KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL,
));
}
fn selected(tui: &MainTui) -> Option<usize> {
tui.state.results_table_state.selected()
}
fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
terminal.draw(|frame| tui.render(frame)).expect("draw");
terminal.backend().to_string()
}
fn screen_of(tui: &mut MainTui) -> String {
screen_sized(tui, 160, 40)
}
fn mouse(
tui: &mut MainTui,
kind: ratatui::crossterm::event::MouseEventKind,
column: u16,
row: u16,
) {
tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
});
}
fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1);
mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1);
mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1);
}
/// The screen row carrying a pane's legend, `[2] Results` say, without
/// the quotes the test backend wraps each row in.
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str {
let line = screen
.lines()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}"));
let start = line.find('"').map_or(0, |i| i + 1);
let end = line.rfind('"').unwrap_or(line.len());
&line[start..end]
}
/// A window with a search, its results, and a transfer, so every pane
/// has something to show.
fn furnished_tui() -> MainTui {
let mut tui = results_tui(3);
tui.state.searches.push(crate::models::SearchEntry {
query: "aphex twin".to_string(),
status: SearchStatus::Completed,
results: results(3),
known_files: 3,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.selected_search_index = Some(0);
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "@@x\\Music\\00.mp3"),
receiver: None,
});
tui
}
fn browsed(tui: &mut MainTui, files: usize) {
tui.state.browse.open("bob");
let listing = vec![soulseek_rs::SharedDirectory {
name: "Music".to_string(),
files: (0..files)
.map(|i| soulseek_rs::SharedFileEntry {
name: format!("{i:02}.mp3"),
size: 1,
attributes: Vec::new(),
})
.collect(),
}];
tui.state
.browse
.active_tab_mut()
.expect("tab")
.load(&listing);
tui.state.show_browse = true;
let _ = screen_of(tui);
}
fn in_a_room(tui: &mut MainTui, messages: usize) {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Joined {
room: "jazz".to_string(),
users: (0..30).map(|i| format!("user{i:02}")).collect(),
},
None,
);
tui.state.rooms.focus_or_open("jazz");
for i in 0..messages {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Message {
room: "jazz".to_string(),
username: "alice".to_string(),
message: format!("line {i:03}"),
},
Some("jazz"),
);
}
tui.state.show_rooms = true;
let _ = screen_of(tui);
}
/// The queries the window has sent so far, once at least `count` of them
/// are out: a search goes to the session from a worker thread, so a test
/// has to give it a moment.
fn searched(session: &TalkativeSession, count: usize) -> Vec<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let seen = session.searched.lock().expect("not poisoned").clone();
if seen.len() >= count || std::time::Instant::now() > deadline {
return seen;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// A window over last session's search list: the queries came back from
/// disk, with nothing under them.
fn restored(session: Arc<TalkativeSession>, queries: &[&str]) -> MainTui {
let mut tui = attach(session);
let queries: Vec<String> =
queries.iter().map(ToString::to_string).collect();
restore_searches(&mut tui.state, &queries);
tui.state.focused_pane = FocusedPane::Searches;
tui
}