diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index 0e5e4f2..150de3e 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -59,6 +59,7 @@ use std::time::{Duration, Instant}; use crate::{SoulseekRs, debug, error, trace, warn}; +mod handlers; mod types; pub use types::{Context, PeerAddress, UserMessage}; @@ -471,497 +472,6 @@ impl ServerActor { )); } - fn handle_message(&mut self, msg: ServerMessage) { - if !matches!(self.connection_state, ConnectionState::Connected) - && !matches!( - &msg, - ServerMessage::ProcessRead | ServerMessage::Login { .. } - ) - { - self.queued_messages.push(msg); - return; - } - - match msg { - ServerMessage::ConnectToPeer(peer) => { - self.handle_connect_to_peer(peer); - } - ServerMessage::LoginStatus(message) => { - self.handle_login_status(message); - } - ServerMessage::Relogged => self.handle_relogged(), - ServerMessage::PierceFirewall(token) => { - self.send_message( - MessageFactory::build_pierce_firewall_message(token), - ); - } - ServerMessage::SendMessage(message) => { - self.send_message(message); - } - ServerMessage::GetPeerAddress(username) => { - self.send_message(MessageFactory::build_get_peer_address( - &username, - )); - } - ServerMessage::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - } => { - self.handle_get_peer_address_response( - username, - host, - port, - obfuscation_type, - obfuscated_port, - ); - } - ServerMessage::PrivateMessageReceived(user_message) => { - self.handle_private_message_received(user_message); - } - ServerMessage::ProcessRead => { - self.process_read(); - } - ServerMessage::Login { - username, - password, - version, - response, - } => { - self.handle_login(username, password, version, response); - } - ServerMessage::FileSearch { token, query } => { - self.file_search(token, &query); - } - ServerMessage::FileSearchRequest { - username, - token, - query, - } => { - self.handle_file_search_request(username, token, query); - } - other => self.handle_social_message(other), - } - } - - /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events - /// and user status or stats. Split out, like the standing traffic below, - /// only to keep `handle_message`'s match readable. - fn handle_social_message(&mut self, message: ServerMessage) { - match message { - ServerMessage::RoomListReceived(rooms) => { - self.forward_room_event(RoomEvent::List(rooms)); - } - ServerMessage::UserStatusReceived { - username, - status, - privileged, - } => { - self.forward_to_client(ClientOperation::UserStatusReceived { - username, - status, - privileged, - }); - } - ServerMessage::WatchedUserReceived { - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - } => { - self.forward_to_client(ClientOperation::WatchedUserReceived { - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - }); - } - ServerMessage::UserStatsReceived { - username, - average_speed, - shared_files, - shared_folders, - } => { - self.forward_to_client(ClientOperation::UserStatsReceived { - username, - average_speed, - shared_files, - shared_folders, - }); - } - ServerMessage::RoomJoined { room, users } => { - self.forward_room_event(RoomEvent::Joined { room, users }); - } - ServerMessage::RoomMemberStats { room, stats } => { - self.forward_to_client(ClientOperation::RoomMemberStats { - room, - stats, - }); - } - ServerMessage::RoomLeft { room } => { - self.forward_room_event(RoomEvent::Left { room }); - } - ServerMessage::RoomMessageReceived { - room, - username, - message, - } => { - self.forward_room_event(RoomEvent::Message { - room, - username, - message, - }); - } - ServerMessage::RoomUserJoined { room, username } => { - self.forward_room_event(RoomEvent::UserJoined { - room, - username, - }); - } - ServerMessage::RoomUserLeft { room, username } => { - self.forward_room_event(RoomEvent::UserLeft { room, username }); - } - ServerMessage::RoomTickers { room, tickers } => { - self.forward_room_event(RoomEvent::Tickers { room, tickers }); - } - ServerMessage::RoomTickerAdded { - room, - username, - ticker, - } => { - self.forward_room_event(RoomEvent::TickerAdded { - room, - username, - ticker, - }); - } - ServerMessage::RoomTickerRemoved { room, username } => { - self.forward_room_event(RoomEvent::TickerRemoved { - room, - username, - }); - } - ServerMessage::GlobalRoomMessageReceived { - room, - username, - message, - } => { - self.forward_room_event(RoomEvent::GlobalMessage { - room, - username, - message, - }); - } - other => self.handle_standing_message(other), - } - } - - /// The wishlist and privilege traffic: standing searches (codes 103/104) and - /// who is privileged (codes 69/92). - /// - /// Split out only because it keeps `handle_message`'s match to a readable - /// length; there is no behaviour here beyond dispatch. - fn handle_standing_message(&mut self, message: ServerMessage) { - match message { - ServerMessage::PossibleParents(candidates) => { - self.forward_to_client(ClientOperation::PossibleParents( - candidates, - )); - } - ServerMessage::ResetDistributed => { - self.forward_to_client(ClientOperation::ResetDistributed); - } - ServerMessage::WishlistSearch { token, query } => { - self.queue_message(MessageFactory::build_wishlist_search( - token, &query, - )); - } - ServerMessage::WishlistInterval(seconds) => { - self.forward_to_client(ClientOperation::WishlistInterval( - seconds, - )); - } - ServerMessage::PrivilegedUsers(users) => { - self.forward_to_client(ClientOperation::PrivilegedUsers(users)); - } - ServerMessage::OwnPrivileges(seconds) => { - self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); - } - ServerMessage::CheckPrivileges => { - self.queue_message(MessageFactory::build_check_privileges()); - } - ServerMessage::RecommendationsReceived { - recommended, - unrecommended, - } => { - self.forward_to_client(ClientOperation::Recommendations { - global: false, - recommended, - unrecommended, - }); - } - ServerMessage::GlobalRecommendationsReceived { - recommended, - unrecommended, - } => { - self.forward_to_client(ClientOperation::Recommendations { - global: true, - recommended, - unrecommended, - }); - } - ServerMessage::ItemRecommendationsReceived { - item, - recommendations, - } => { - self.forward_to_client(ClientOperation::ItemRecommendations { - item, - recommendations, - }); - } - ServerMessage::SimilarUsersReceived { users } => { - self.forward_to_client(ClientOperation::SimilarUsers(users)); - } - ServerMessage::ItemSimilarUsersReceived { item, usernames } => { - self.forward_to_client(ClientOperation::ItemSimilarUsers { - item, - usernames, - }); - } - ServerMessage::UserInterestsReceived(interests) => { - self.forward_to_client(ClientOperation::UserInterests( - interests, - )); - } - ServerMessage::CantConnectToPeer { token } => { - self.forward_to_client(ClientOperation::CantConnectToPeer { - token, - }); - } - ServerMessage::PrivateRoomMembers { room, users } => { - self.forward_room_event(RoomEvent::PrivateMembers { - room, - users, - }); - } - ServerMessage::PrivateRoomOperators { room, users } => { - self.forward_room_event(RoomEvent::PrivateOperators { - room, - users, - }); - } - ServerMessage::PrivateRoomRosterChanged { - room, - username, - members, - added, - } => { - self.forward_room_event(RoomEvent::PrivateRosterChanged { - room, - username, - members, - added, - }); - } - ServerMessage::OwnRoomStandingChanged { - room, - members, - granted, - } => { - self.forward_room_event(RoomEvent::OwnStandingChanged { - room, - members, - granted, - }); - } - ServerMessage::CantCreateRoom { room } => { - self.forward_room_event(RoomEvent::CantCreate { room }); - } - ServerMessage::ParentMinSpeed(speed) => { - self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); - } - ServerMessage::ParentSpeedRatio(ratio) => { - self.forward_to_client(ClientOperation::ParentSpeedRatio( - ratio, - )); - } - ServerMessage::ExcludedSearchPhrases(phrases) => { - self.forward_to_client(ClientOperation::ExcludedSearchPhrases( - phrases, - )); - } - other => { - error!("[server] unroutable message: {:?}", other); - } - } - } - - fn handle_connect_to_peer(&self, peer: Peer) { - if let Some(op) = match peer.connection_type { - ConnectionType::P | ConnectionType::F => { - Some(ClientOperation::ConnectToPeer(peer)) - } - ConnectionType::D => None, - } && let Err(e) = self.client_channel.send(op) - { - error!("[server] failed to send ConnectToPeer: {}", e); - } - } - - fn handle_login_status(&mut self, message: bool) { - // Send the post-login handshake exactly once, only on success, - // on the live path (the old ServerActor::login did this but was - // never called). Advertises real shared counts and, when - // listening, the port peers must connect to. - if message { - for msg in post_login_messages( - self.enable_listen, - self.listen_port, - self.shared_folder_count, - self.shared_file_count, - ) { - self.send_message(msg); - } - self.session.clear(); - // The distributed stance is the leaf's to announce, and a new - // session starts without a parent. - self.forward_to_client(ClientOperation::ResetDistributed); - // The server holds interests only for the life of a session. - self.forward_to_client(ClientOperation::SessionEstablished); - } - match self.context.write_safe() { - Ok(mut ctx) => ctx.logged_in = Some(message), - Err(e) => { - error!("[server] LoginStatus write: {}", e); - } - } - } - - fn handle_get_peer_address_response( - &self, - username: String, - host: String, - port: u32, - obfuscation_type: u32, - obfuscated_port: u16, - ) { - debug!( - "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", - username, host, port, obfuscation_type, obfuscated_port - ); - - if let Err(e) = - self.client_channel - .send(ClientOperation::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - }) - { - error!( - "[server] Error forwarding GetPeerAddress response to client: {}", - e - ); - } - } - - /// Hand an operation to the client loop, logging a dead channel rather - /// than unwinding the actor. - fn forward_to_client(&self, operation: ClientOperation) { - if let Err(e) = self.client_channel.send(operation) { - error!("[server] Error forwarding to client: {}", e); - } - } - - fn handle_private_message_received(&self, user_message: UserMessage) { - debug!("[server] Private message from {}", user_message.username()); - if let Err(e) = self - .client_channel - .send(ClientOperation::PrivateMessageReceived(user_message)) - { - error!( - "[server] Error forwarding private message to client: {}", - e - ); - } - } - - fn handle_login( - &mut self, - username: String, - password: String, - version: ClientVersion, - response: std::sync::mpsc::Sender>, - ) { - if self.stream.is_none() && !self.initiate_connection() { - let _ = response.send(Err(SoulseekRs::NotConnected)); - return; - } - if let Ok(mut ctx) = self.context.write_safe() { - ctx.logged_in = None; - } - self.queue_message(MessageFactory::build_login_message( - &username, &password, version, - )); - - let start = std::time::Instant::now(); - - let context = self.context.clone(); - std::thread::spawn(move || { - loop { - if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { - let _ = response.send(Err(SoulseekRs::Timeout)); - break; - } - - let logged_in = match context.read_safe() { - Ok(ctx) => ctx.logged_in, - Err(e) => { - let _ = response.send(Err(e)); - break; - } - }; - if let Some(logged_in) = logged_in { - let result = if logged_in { - Ok(true) - } else { - Err(SoulseekRs::AuthenticationFailed) - }; - let _ = response.send(result); - break; - } - - std::thread::sleep(Duration::from_millis(100)); - } - }); - } - - fn handle_file_search_request( - &self, - username: String, - token: u32, - query: String, - ) { - if let Err(e) = - self.client_channel.send(ClientOperation::IncomingSearch { - from_parent: false, - username, - token, - query, - }) - { - error!("[server] forward IncomingSearch: {}", e); - } - } - fn process_read(&mut self) { if self.reader.buffer_len() > 0 { self.extract_and_process_messages(); diff --git a/soulseek-rs-lib/src/actor/server_actor/handlers.rs b/soulseek-rs-lib/src/actor/server_actor/handlers.rs new file mode 100644 index 0000000..072cb10 --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor/handlers.rs @@ -0,0 +1,528 @@ +//! What the actor does with a message once it has one: the dispatch and the +//! per-message handlers behind it. +//! +//! A child module, so these keep reaching the actor's private state without +//! any of it being widened for the move. + +use super::{ + ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration, + LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt, + ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error, + post_login_messages, +}; + +impl ServerActor { + pub(super) fn handle_message(&mut self, msg: ServerMessage) { + if !matches!(self.connection_state, ConnectionState::Connected) + && !matches!( + &msg, + ServerMessage::ProcessRead | ServerMessage::Login { .. } + ) + { + self.queued_messages.push(msg); + return; + } + + match msg { + ServerMessage::ConnectToPeer(peer) => { + self.handle_connect_to_peer(peer); + } + ServerMessage::LoginStatus(message) => { + self.handle_login_status(message); + } + ServerMessage::Relogged => self.handle_relogged(), + ServerMessage::PierceFirewall(token) => { + self.send_message( + MessageFactory::build_pierce_firewall_message(token), + ); + } + ServerMessage::SendMessage(message) => { + self.send_message(message); + } + ServerMessage::GetPeerAddress(username) => { + self.send_message(MessageFactory::build_get_peer_address( + &username, + )); + } + ServerMessage::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + } => { + self.handle_get_peer_address_response( + username, + host, + port, + obfuscation_type, + obfuscated_port, + ); + } + ServerMessage::PrivateMessageReceived(user_message) => { + self.handle_private_message_received(user_message); + } + ServerMessage::ProcessRead => { + self.process_read(); + } + ServerMessage::Login { + username, + password, + version, + response, + } => { + self.handle_login(username, password, version, response); + } + ServerMessage::FileSearch { token, query } => { + self.file_search(token, &query); + } + ServerMessage::FileSearchRequest { + username, + token, + query, + } => { + self.handle_file_search_request(username, token, query); + } + other => self.handle_social_message(other), + } + } + + /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events + /// and user status or stats. Split out, like the standing traffic below, + /// only to keep `handle_message`'s match readable. + fn handle_social_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::RoomListReceived(rooms) => { + self.forward_room_event(RoomEvent::List(rooms)); + } + ServerMessage::UserStatusReceived { + username, + status, + privileged, + } => { + self.forward_to_client(ClientOperation::UserStatusReceived { + username, + status, + privileged, + }); + } + ServerMessage::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::RoomJoined { room, users } => { + self.forward_room_event(RoomEvent::Joined { room, users }); + } + ServerMessage::RoomMemberStats { room, stats } => { + self.forward_to_client(ClientOperation::RoomMemberStats { + room, + stats, + }); + } + ServerMessage::RoomLeft { room } => { + self.forward_room_event(RoomEvent::Left { room }); + } + ServerMessage::RoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::Message { + room, + username, + message, + }); + } + ServerMessage::RoomUserJoined { room, username } => { + self.forward_room_event(RoomEvent::UserJoined { + room, + username, + }); + } + ServerMessage::RoomUserLeft { room, username } => { + self.forward_room_event(RoomEvent::UserLeft { room, username }); + } + ServerMessage::RoomTickers { room, tickers } => { + self.forward_room_event(RoomEvent::Tickers { room, tickers }); + } + ServerMessage::RoomTickerAdded { + room, + username, + ticker, + } => { + self.forward_room_event(RoomEvent::TickerAdded { + room, + username, + ticker, + }); + } + ServerMessage::RoomTickerRemoved { room, username } => { + self.forward_room_event(RoomEvent::TickerRemoved { + room, + username, + }); + } + ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::GlobalMessage { + room, + username, + message, + }); + } + other => self.handle_standing_message(other), + } + } + + /// The wishlist and privilege traffic: standing searches (codes 103/104) and + /// who is privileged (codes 69/92). + /// + /// Split out only because it keeps `handle_message`'s match to a readable + /// length; there is no behaviour here beyond dispatch. + fn handle_standing_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::PossibleParents(candidates) => { + self.forward_to_client(ClientOperation::PossibleParents( + candidates, + )); + } + ServerMessage::ResetDistributed => { + self.forward_to_client(ClientOperation::ResetDistributed); + } + ServerMessage::WishlistSearch { token, query } => { + self.queue_message(MessageFactory::build_wishlist_search( + token, &query, + )); + } + ServerMessage::WishlistInterval(seconds) => { + self.forward_to_client(ClientOperation::WishlistInterval( + seconds, + )); + } + ServerMessage::PrivilegedUsers(users) => { + self.forward_to_client(ClientOperation::PrivilegedUsers(users)); + } + ServerMessage::OwnPrivileges(seconds) => { + self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); + } + ServerMessage::CheckPrivileges => { + self.queue_message(MessageFactory::build_check_privileges()); + } + message @ (ServerMessage::RecommendationsReceived { .. } + | ServerMessage::GlobalRecommendationsReceived { + .. + } + | ServerMessage::ItemRecommendationsReceived { + .. + } + | ServerMessage::SimilarUsersReceived { .. } + | ServerMessage::ItemSimilarUsersReceived { .. } + | ServerMessage::UserInterestsReceived(..)) => { + self.handle_taste_message(message); + } + ServerMessage::CantConnectToPeer { token } => { + self.forward_to_client(ClientOperation::CantConnectToPeer { + token, + }); + } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } + ServerMessage::ParentMinSpeed(speed) => { + self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); + } + ServerMessage::ParentSpeedRatio(ratio) => { + self.forward_to_client(ClientOperation::ParentSpeedRatio( + ratio, + )); + } + ServerMessage::ExcludedSearchPhrases(phrases) => { + self.forward_to_client(ClientOperation::ExcludedSearchPhrases( + phrases, + )); + } + other => { + error!("[server] unroutable message: {:?}", other); + } + } + } + + /// What the server says about taste: the recommendation and similar-user + /// answers, and a user's interests. Split off `handle_standing_message` + /// only to keep that dispatch readable — these arms share nothing with + /// the rest of it. + fn handle_taste_message(&self, message: ServerMessage) { + match message { + ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: false, + recommended, + unrecommended, + }); + } + ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: true, + recommended, + unrecommended, + }); + } + ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + } => { + self.forward_to_client(ClientOperation::ItemRecommendations { + item, + recommendations, + }); + } + ServerMessage::SimilarUsersReceived { users } => { + self.forward_to_client(ClientOperation::SimilarUsers(users)); + } + ServerMessage::ItemSimilarUsersReceived { item, usernames } => { + self.forward_to_client(ClientOperation::ItemSimilarUsers { + item, + usernames, + }); + } + ServerMessage::UserInterestsReceived(interests) => { + self.forward_to_client(ClientOperation::UserInterests( + interests, + )); + } + // `handle_standing_message` routes only the variants above here. + _ => {} + } + } + + fn handle_connect_to_peer(&self, peer: Peer) { + if let Some(op) = match peer.connection_type { + ConnectionType::P | ConnectionType::F => { + Some(ClientOperation::ConnectToPeer(peer)) + } + ConnectionType::D => None, + } && let Err(e) = self.client_channel.send(op) + { + error!("[server] failed to send ConnectToPeer: {}", e); + } + } + + pub(super) fn handle_login_status(&mut self, message: bool) { + // Send the post-login handshake exactly once, only on success, + // on the live path (the old ServerActor::login did this but was + // never called). Advertises real shared counts and, when + // listening, the port peers must connect to. + if message { + for msg in post_login_messages( + self.enable_listen, + self.listen_port, + self.shared_folder_count, + self.shared_file_count, + ) { + self.send_message(msg); + } + self.session.clear(); + // The distributed stance is the leaf's to announce, and a new + // session starts without a parent. + self.forward_to_client(ClientOperation::ResetDistributed); + // The server holds interests only for the life of a session. + self.forward_to_client(ClientOperation::SessionEstablished); + } + match self.context.write_safe() { + Ok(mut ctx) => ctx.logged_in = Some(message), + Err(e) => { + error!("[server] LoginStatus write: {}", e); + } + } + } + + fn handle_get_peer_address_response( + &self, + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + ) { + debug!( + "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", + username, host, port, obfuscation_type, obfuscated_port + ); + + if let Err(e) = + self.client_channel + .send(ClientOperation::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }) + { + error!( + "[server] Error forwarding GetPeerAddress response to client: {}", + e + ); + } + } + + /// Hand an operation to the client loop, logging a dead channel rather + /// than unwinding the actor. + fn forward_to_client(&self, operation: ClientOperation) { + if let Err(e) = self.client_channel.send(operation) { + error!("[server] Error forwarding to client: {}", e); + } + } + + fn handle_private_message_received(&self, user_message: UserMessage) { + debug!("[server] Private message from {}", user_message.username()); + if let Err(e) = self + .client_channel + .send(ClientOperation::PrivateMessageReceived(user_message)) + { + error!( + "[server] Error forwarding private message to client: {}", + e + ); + } + } + + pub(super) fn handle_login( + &mut self, + username: String, + password: String, + version: ClientVersion, + response: std::sync::mpsc::Sender>, + ) { + if self.stream.is_none() && !self.initiate_connection() { + let _ = response.send(Err(SoulseekRs::NotConnected)); + return; + } + if let Ok(mut ctx) = self.context.write_safe() { + ctx.logged_in = None; + } + self.queue_message(MessageFactory::build_login_message( + &username, &password, version, + )); + + let start = std::time::Instant::now(); + + let context = self.context.clone(); + std::thread::spawn(move || { + loop { + if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { + let _ = response.send(Err(SoulseekRs::Timeout)); + break; + } + + let logged_in = match context.read_safe() { + Ok(ctx) => ctx.logged_in, + Err(e) => { + let _ = response.send(Err(e)); + break; + } + }; + if let Some(logged_in) = logged_in { + let result = if logged_in { + Ok(true) + } else { + Err(SoulseekRs::AuthenticationFailed) + }; + let _ = response.send(result); + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + }); + } + + fn handle_file_search_request( + &self, + username: String, + token: u32, + query: String, + ) { + if let Err(e) = + self.client_channel.send(ClientOperation::IncomingSearch { + from_parent: false, + username, + token, + query, + }) + { + error!("[server] forward IncomingSearch: {}", e); + } + } +} diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs new file mode 100644 index 0000000..b51a58a --- /dev/null +++ b/soulseek-rs-lib/src/client/context.rs @@ -0,0 +1,761 @@ +//! What the client's shared context does, as opposed to what it holds. +//! +//! A child module rather than a sibling: the state itself stays declared in +//! `client`, so these methods keep reaching its private fields without any +//! of them having to be widened for the move. + +use super::{ + ActorSystem, Arc, AtomicUsize, BROKER_CONNECT_TIMEOUT, + BROWSE_PROTECT_WINDOW, ClientContext, DEFAULT_MAX_PEERS, + DEFAULT_UPLOAD_SLOTS, DownloadStatus, DownloadStore, HashMap, HashSet, + Instant, PENDING_PEER_TTL, Recommendation, RoomEvent, RoomInfo, RoomTicker, + RoomUserStats, SharedDirectory, Shares, SimilarUser, UserInfo, + UserInterests, UserMessage, UserPresence, UserStats, UserStatus, children, + distributed, sorted_unique, +}; + +impl ClientContext { + /// A context for the client that logs in as `username`. + #[must_use] + pub fn for_user(username: &str) -> Self { + Self { + leaf: distributed::Leaf::new(username), + ..Self::new() + } + } + + #[must_use] + pub fn new() -> Self { + let actor_system = Arc::new(ActorSystem::new()); + + Self { + peer_registry: None, + server_sender: None, + operations: None, + leaf: distributed::Leaf::new(""), + children: children::Children::default(), + announced_accept_children: None, + own_average_speed: None, + parent_min_speed: 0, + parent_speed_ratio: 0, + searches: HashMap::new(), + private_messages: Vec::new(), + pending_connect_tokens: HashMap::new(), + max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), + shares: Arc::new(Shares::empty()), + shared_directories: Vec::new(), + peer_addresses: HashMap::new(), + pending_peer_messages: HashMap::new(), + uploads: HashMap::new(), + active_uploads: HashMap::new(), + pending_serves: HashMap::new(), + browse_results: HashMap::new(), + folder_contents: HashMap::new(), + peer_infos: HashMap::new(), + pending_browses: HashMap::new(), + room_list: Vec::new(), + room_events: Vec::new(), + room_members: HashMap::new(), + room_member_stats: HashMap::new(), + room_tickers: HashMap::new(), + private_room_members: HashMap::new(), + private_room_operators: HashMap::new(), + recommendations: None, + global_recommendations: None, + item_recommendations: HashMap::new(), + similar_users: Vec::new(), + item_similar_users: HashMap::new(), + user_interests: HashMap::new(), + own_interests: UserInterests::default(), + user_info: HashMap::new(), + watched_users: HashSet::new(), + wishlist_interval: None, + privileged_users: HashSet::new(), + own_privileges: None, + excluded_search_phrases: Vec::new(), + upload_queue: Vec::new(), + upload_seq: 0, + upload_slots: DEFAULT_UPLOAD_SLOTS, + last_upload_speed: 0, + upload_events: Vec::new(), + downloads: DownloadStore::new(), + actor_system, + } + } + + /// Apply a chat-room event: keep the room-list snapshot and the per-room + /// rosters current, then queue the event for the client/UI to drain. + pub fn apply_room_event(&mut self, event: RoomEvent) { + match &event { + RoomEvent::List(rooms) => self.room_list.clone_from(rooms), + RoomEvent::Joined { room, users } => { + let mut members = users.clone(); + members.sort(); + members.dedup(); + self.room_members.insert(room.clone(), members); + } + RoomEvent::Left { room } => { + self.room_members.remove(room); + // A board for a room we are not in is stale; the server sends + // the whole board again on the next join. + self.room_tickers.remove(room); + } + RoomEvent::UserJoined { room, username } => { + let members = + self.room_members.entry(room.clone()).or_default(); + if let Err(at) = members.binary_search(username) { + members.insert(at, username.clone()); + } + } + RoomEvent::UserLeft { room, username } => { + if let Some(members) = self.room_members.get_mut(room) + && let Ok(at) = members.binary_search(username) + { + members.remove(at); + } + } + RoomEvent::Tickers { room, tickers } => { + self.room_tickers.insert(room.clone(), tickers.clone()); + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + // A user has at most one ticker: an added one replaces theirs + // rather than stacking, which is how the server treats it. + let board = self.room_tickers.entry(room.clone()).or_default(); + board.retain(|t| &t.username != username); + board.push(RoomTicker { + username: username.clone(), + ticker: ticker.clone(), + }); + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(board) = self.room_tickers.get_mut(room) { + board.retain(|t| &t.username != username); + } + } + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Losing membership takes both rosters with it: a room we + // cannot enter has none worth showing. Losing operatorship + // leaves us a member who still sees them, and the server + // narrates that roster change separately (code 144). + if !granted && *members { + self.private_room_members.remove(room); + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} + } + self.room_events.push(event); + } + + /// Remember an interest of our own so it can be sent again next login. + /// Interests are matched by the server case-insensitively, and Nicotine+ + /// lowercases them before sending; do the same so two spellings of one + /// interest cannot both be held. + /// Returns the stored spelling, or `None` for an empty item — which is + /// no interest, and must not be sent to the server either. + pub fn add_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + if !list.contains(&item) { + list.push(item.clone()); + } + Some(item) + } + + /// Forget one of our own interests. `None` for an empty item, as above. + pub fn remove_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + list.retain(|held| held != &item); + Some(item) + } + + /// What we like and hate, as last set. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + self.own_interests.clone() + } + + /// Take the `AcceptChildren` answer to send, or `None` when the server + /// already has this one. + pub fn accept_children_change(&mut self) -> Option { + let has_room = self.children.has_room(); + (self.announced_accept_children != Some(has_room)).then(|| { + self.announced_accept_children = Some(has_room); + has_room + }) + } + + /// The upload speed the server records for us, if it has said. + #[must_use] + pub const fn own_average_speed(&self) -> Option { + self.own_average_speed + } + + /// Record what the server says our own upload speed is, and re-derive the + /// child limit from it. + pub fn set_own_average_speed(&mut self, speed: u32) { + self.own_average_speed = Some(speed); + self.apply_child_limits(); + } + + /// Record the server's parent figures (codes 83 and 84). + pub fn set_parent_min_speed(&mut self, speed: u32) { + self.parent_min_speed = speed; + self.apply_child_limits(); + } + + pub fn set_parent_speed_ratio(&mut self, ratio: u32) { + self.parent_speed_ratio = ratio; + self.apply_child_limits(); + } + + fn apply_child_limits(&mut self) { + self.children.set_limits( + self.own_average_speed, + self.parent_min_speed, + self.parent_speed_ratio, + ); + } + + /// Whether something is feeding us the distributed search stream: a + /// parent of our own, or the server relaying searches to us. Children may + /// only hang from a client that has one. + pub fn set_fed_by(&mut self, fed: bool) { + self.children.set_fed(fed); + if !fed { + self.children.drop_all(); + } + } + + /// Record what `username` says about itself. + pub fn store_peer_info( + &mut self, + username: String, + info: crate::message::peer::PeerInfo, + ) { + self.peer_infos.insert(username, info); + } + + /// What `username` last said about itself, if anything. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.peer_infos.get(username).cloned() + } + + /// Drop what we hold about `username`, so a poll after a fresh request + /// cannot report the previous answer. + pub fn invalidate_peer_info(&mut self, username: &str) { + self.peer_infos.remove(username); + } + + /// Record one folder's listing from `username`. + pub fn store_folder_contents( + &mut self, + username: String, + folder: String, + directories: Vec, + ) { + self.folder_contents.insert((username, folder), directories); + } + + /// Remove and return the listing of `folder` from `username`, if it has + /// arrived. + pub fn take_folder_contents( + &mut self, + username: &str, + folder: &str, + ) -> Option> { + self.folder_contents + .remove(&(username.to_string(), folder.to_string())) + } + + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + + /// The ticker board of `room` as last reported by the server. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + self.room_tickers.get(room).cloned().unwrap_or_default() + } + + /// Record a recommendations reply; `global` selects the server-wide set. + pub fn apply_recommendations( + &mut self, + global: bool, + recommended: Vec, + unrecommended: Vec, + ) { + let slot = if global { + &mut self.global_recommendations + } else { + &mut self.recommendations + }; + *slot = Some((recommended, unrecommended)); + } + + /// The last recommendations reply, or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + global: bool, + ) -> Option<(Vec, Vec)> { + if global { + self.global_recommendations.clone() + } else { + self.recommendations.clone() + } + } + + pub fn apply_item_recommendations( + &mut self, + item: String, + recommendations: Vec, + ) { + self.item_recommendations.insert(item, recommendations); + } + + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + self.item_recommendations + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_similar_users(&mut self, users: Vec) { + self.similar_users = users; + } + + #[must_use] + pub fn similar_users(&self) -> Vec { + self.similar_users.clone() + } + + pub fn apply_item_similar_users( + &mut self, + item: String, + usernames: Vec, + ) { + self.item_similar_users.insert(item, usernames); + } + + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + self.item_similar_users + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_user_interests(&mut self, interests: UserInterests) { + self.user_interests + .insert(interests.username.clone(), interests); + } + + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.user_interests.get(username).cloned() + } + + /// Record the phrases the server excludes (code 160), lowercased here so + /// the per-file check is a plain `contains` on a lowercased path. + pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { + self.excluded_search_phrases = + phrases.iter().map(|p| p.to_lowercase()).collect(); + } + + /// The phrases the server refuses to search for. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.excluded_search_phrases.clone() + } + + /// Forget any interests cached for `username`, so a poll after a fresh + /// request cannot mistake the previous answer for this one. + pub fn invalidate_user_interests(&mut self, username: &str) { + self.user_interests.remove(username); + } + + /// Record a `GetUserStatus` reply, merging it with any statistics already + /// received for that user. + pub fn apply_user_status( + &mut self, + username: String, + status: u32, + privileged: bool, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + + /// Forget what we know about `username`, so the next poll reports the + /// answer to the request being made now rather than the previous one. + pub fn invalidate_user_info(&mut self, username: &str) { + self.user_info.remove(username); + } + + /// Record a `GetUserStats` reply, merging it with any status already + /// received for that user. + pub fn apply_user_stats( + &mut self, + username: String, + average_speed: u32, + shared_files: u32, + shared_folders: u32, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .stats = Some(UserStats { + average_speed, + shared_files, + shared_folders, + }); + } + + /// Record that we are now watching `username`. + pub fn add_watched_user(&mut self, username: &str) { + self.watched_users.insert(username.to_string()); + } + + /// Forget a watch, dropping any snapshot we held for that user so a later + /// re-watch reports a fresh answer rather than a stale one. + pub fn remove_watched_user(&mut self, username: &str) { + self.watched_users.remove(username); + self.user_info.remove(username); + } + + /// Everyone we are currently watching, sorted by name. + #[must_use] + pub fn watched_users(&self) -> Vec { + let mut users: Vec = + self.watched_users.iter().cloned().collect(); + users.sort(); + users + } + + /// Record the initial snapshot from a `WatchUser` reply. A username the + /// server does not know carries no stats and is dropped from the watch + /// list, since the server will never push anything for it. + pub fn apply_watched_user( + &mut self, + username: String, + exists: bool, + status: Option, + average_speed: Option, + shared_files: Option, + shared_folders: Option, + ) { + if !exists { + self.watched_users.remove(&username); + return; + } + if let Some(status) = status { + let entry = self + .user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username.clone())); + // The watch reply carries no privileged flag, so keep whatever a + // previous GetUserStatus told us rather than asserting `false`. + let privileged = + entry.presence.as_ref().is_some_and(|p| p.privileged); + entry.presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = + (average_speed, shared_files, shared_folders) + { + self.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + } + } + + /// What the server has said about `username` so far. + #[must_use] + pub fn user_info(&self, username: &str) -> Option { + self.user_info.get(username).cloned() + } + + /// Who is currently in `room`, sorted, or empty when we are not in it. + #[must_use] + pub fn room_members(&self, room: &str) -> Vec { + self.room_members.get(room).cloned().unwrap_or_default() + } + + /// Record the per-member statistics of a room we just joined, replacing + /// any previous snapshot for it. + pub fn apply_room_member_stats( + &mut self, + room: String, + stats: Vec, + ) { + self.room_member_stats.insert(room, stats); + } + + /// What the server reported about the members of `room`, sorted by name, + /// or empty for a room we have not joined. + #[must_use] + pub fn room_member_stats(&self, room: &str) -> Vec { + let mut stats = self + .room_member_stats + .get(room) + .cloned() + .unwrap_or_default(); + stats.sort_by(|a, b| a.username.cmp(&b.username)); + stats + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + self.room_list.clone() + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&mut self) -> Vec { + std::mem::take(&mut self.room_events) + } + + /// Cache a peer's listen address learned from a GetPeerAddress response. + pub fn cache_peer_address( + &mut self, + username: &str, + host: String, + port: u32, + ) { + self.peer_addresses + .insert(username.to_string(), (host, port)); + } + + /// The cached listen address for `username`, if known. + #[must_use] + pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { + self.peer_addresses.get(username).cloned() + } + + /// Queue a peer message to send once a control connection to `username` is up. + pub fn queue_peer_message( + &mut self, + username: &str, + message: crate::message::Message, + ) { + let entry = self + .pending_peer_messages + .entry(username.to_string()) + .or_insert_with(|| (Instant::now(), Vec::new())); + entry.0 = Instant::now(); + entry.1.push(message); + } + + /// Remove and return the messages queued for `username`. + pub fn take_peer_messages( + &mut self, + username: &str, + ) -> Vec { + self.pending_peer_messages + .remove(username) + .map(|(_, messages)| messages) + .unwrap_or_default() + } + + /// Drop peer messages nobody could deliver within + /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. + pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { + self.pending_peer_messages.retain(|_, (since, _)| { + now.duration_since(*since) < PENDING_PEER_TTL + }); + } + + /// Store a shared-file listing received from browsing `username`. + pub fn store_browse_result( + &mut self, + username: String, + directories: Vec, + ) { + self.pending_browses.remove(&username); + self.browse_results.insert(username, directories); + } + + pub(crate) fn mark_browse_pending(&mut self, username: &str) { + let now = Instant::now(); + self.pending_browses.retain(|_, deadline| *deadline > now); + self.pending_browses + .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); + } + + /// Remove and return the shared-file listing browsed from `username`. + pub fn take_browse_result( + &mut self, + username: &str, + ) -> Option> { + self.browse_results.remove(username) + } + + /// Remember that a server-brokered connection to `username` is pending under + /// `token`; the peer will quote it back in a PierceFirewall. + pub fn add_pending_connect(&mut self, token: u32, username: String) { + self.pending_connect_tokens + .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); + } + + /// Resolve and consume the peer expected for a brokered connection `token`. + pub fn take_pending_connect(&mut self, token: u32) -> Option { + self.pending_connect_tokens + .remove(&token) + .map(|(username, _)| username) + } + + pub fn take_expired_connects(&mut self, now: Instant) -> Vec { + let mut expired = Vec::new(); + self.pending_connect_tokens + .retain(|_, (username, deadline)| { + if now >= *deadline { + expired.push(std::mem::take(username)); + return false; + } + true + }); + expired + } + + pub(crate) fn protected_peers(&self) -> HashSet { + let mut protected: HashSet = self + .downloads + .list() + .iter() + .filter(|d| { + matches!( + d.status, + DownloadStatus::Queued + | DownloadStatus::InProgress { .. } + | DownloadStatus::Paused { .. } + ) + }) + .map(|d| d.username.clone()) + .collect(); + protected + .extend(self.active_uploads.values().map(|u| u.username.clone())); + protected.extend(self.uploads.values().map(|j| j.downloader.clone())); + protected + .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); + protected.extend(self.pending_serves.keys().cloned()); + protected.extend(self.pending_peer_messages.keys().cloned()); + let now = Instant::now(); + protected.extend( + self.pending_browses + .iter() + .filter(|(_, deadline)| **deadline > now) + .map(|(username, _)| username.clone()), + ); + protected + } + + /// Record a private message received from another user. + pub fn push_private_message(&mut self, message: UserMessage) { + self.private_messages.push(message); + } + + /// Remove and return all buffered private messages. + pub fn take_private_messages(&mut self) -> Vec { + std::mem::take(&mut self.private_messages) + } +} diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index a8ff5c9..21d7dfd 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -629,751 +629,6 @@ impl ClientContext { } } -impl ClientContext { - /// A context for the client that logs in as `username`. - #[must_use] - pub fn for_user(username: &str) -> Self { - Self { - leaf: distributed::Leaf::new(username), - ..Self::new() - } - } - - #[must_use] - pub fn new() -> Self { - let actor_system = Arc::new(ActorSystem::new()); - - Self { - peer_registry: None, - server_sender: None, - operations: None, - leaf: distributed::Leaf::new(""), - children: children::Children::default(), - announced_accept_children: None, - own_average_speed: None, - parent_min_speed: 0, - parent_speed_ratio: 0, - searches: HashMap::new(), - private_messages: Vec::new(), - pending_connect_tokens: HashMap::new(), - max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), - shares: Arc::new(Shares::empty()), - shared_directories: Vec::new(), - peer_addresses: HashMap::new(), - pending_peer_messages: HashMap::new(), - uploads: HashMap::new(), - active_uploads: HashMap::new(), - pending_serves: HashMap::new(), - browse_results: HashMap::new(), - folder_contents: HashMap::new(), - peer_infos: HashMap::new(), - pending_browses: HashMap::new(), - room_list: Vec::new(), - room_events: Vec::new(), - room_members: HashMap::new(), - room_member_stats: HashMap::new(), - room_tickers: HashMap::new(), - private_room_members: HashMap::new(), - private_room_operators: HashMap::new(), - recommendations: None, - global_recommendations: None, - item_recommendations: HashMap::new(), - similar_users: Vec::new(), - item_similar_users: HashMap::new(), - user_interests: HashMap::new(), - own_interests: UserInterests::default(), - user_info: HashMap::new(), - watched_users: HashSet::new(), - wishlist_interval: None, - privileged_users: HashSet::new(), - own_privileges: None, - excluded_search_phrases: Vec::new(), - upload_queue: Vec::new(), - upload_seq: 0, - upload_slots: DEFAULT_UPLOAD_SLOTS, - last_upload_speed: 0, - upload_events: Vec::new(), - downloads: DownloadStore::new(), - actor_system, - } - } - - /// Apply a chat-room event: keep the room-list snapshot and the per-room - /// rosters current, then queue the event for the client/UI to drain. - pub fn apply_room_event(&mut self, event: RoomEvent) { - match &event { - RoomEvent::List(rooms) => self.room_list.clone_from(rooms), - RoomEvent::Joined { room, users } => { - let mut members = users.clone(); - members.sort(); - members.dedup(); - self.room_members.insert(room.clone(), members); - } - RoomEvent::Left { room } => { - self.room_members.remove(room); - // A board for a room we are not in is stale; the server sends - // the whole board again on the next join. - self.room_tickers.remove(room); - } - RoomEvent::UserJoined { room, username } => { - let members = - self.room_members.entry(room.clone()).or_default(); - if let Err(at) = members.binary_search(username) { - members.insert(at, username.clone()); - } - } - RoomEvent::UserLeft { room, username } => { - if let Some(members) = self.room_members.get_mut(room) - && let Ok(at) = members.binary_search(username) - { - members.remove(at); - } - } - RoomEvent::Tickers { room, tickers } => { - self.room_tickers.insert(room.clone(), tickers.clone()); - } - RoomEvent::TickerAdded { - room, - username, - ticker, - } => { - // A user has at most one ticker: an added one replaces theirs - // rather than stacking, which is how the server treats it. - let board = self.room_tickers.entry(room.clone()).or_default(); - board.retain(|t| &t.username != username); - board.push(RoomTicker { - username: username.clone(), - ticker: ticker.clone(), - }); - } - RoomEvent::TickerRemoved { room, username } => { - if let Some(board) = self.room_tickers.get_mut(room) { - board.retain(|t| &t.username != username); - } - } - RoomEvent::PrivateMembers { room, users } => { - self.private_room_members - .insert(room.clone(), sorted_unique(users)); - } - RoomEvent::PrivateOperators { room, users } => { - self.private_room_operators - .insert(room.clone(), sorted_unique(users)); - } - RoomEvent::PrivateRosterChanged { - room, - username, - members, - added, - } => { - let roster = if *members { - self.private_room_members.entry(room.clone()).or_default() - } else { - self.private_room_operators.entry(room.clone()).or_default() - }; - match (added, roster.binary_search(username)) { - (true, Err(at)) => roster.insert(at, username.clone()), - (false, Ok(at)) => { - roster.remove(at); - } - _ => {} - } - } - RoomEvent::OwnStandingChanged { - room, - members, - granted, - } => { - // Losing membership takes both rosters with it: a room we - // cannot enter has none worth showing. Losing operatorship - // leaves us a member who still sees them, and the server - // narrates that roster change separately (code 144). - if !granted && *members { - self.private_room_members.remove(room); - self.private_room_operators.remove(room); - } - } - RoomEvent::Message { .. } - | RoomEvent::GlobalMessage { .. } - | RoomEvent::CantCreate { .. } => {} - } - self.room_events.push(event); - } - - /// Remember an interest of our own so it can be sent again next login. - /// Interests are matched by the server case-insensitively, and Nicotine+ - /// lowercases them before sending; do the same so two spellings of one - /// interest cannot both be held. - /// Returns the stored spelling, or `None` for an empty item — which is - /// no interest, and must not be sent to the server either. - pub fn add_own_interest( - &mut self, - item: &str, - liked: bool, - ) -> Option { - let item = item.trim().to_lowercase(); - if item.is_empty() { - return None; - } - let list = if liked { - &mut self.own_interests.likes - } else { - &mut self.own_interests.hates - }; - if !list.contains(&item) { - list.push(item.clone()); - } - Some(item) - } - - /// Forget one of our own interests. `None` for an empty item, as above. - pub fn remove_own_interest( - &mut self, - item: &str, - liked: bool, - ) -> Option { - let item = item.trim().to_lowercase(); - if item.is_empty() { - return None; - } - let list = if liked { - &mut self.own_interests.likes - } else { - &mut self.own_interests.hates - }; - list.retain(|held| held != &item); - Some(item) - } - - /// What we like and hate, as last set. - #[must_use] - pub fn own_interests(&self) -> UserInterests { - self.own_interests.clone() - } - - /// Take the `AcceptChildren` answer to send, or `None` when the server - /// already has this one. - pub fn accept_children_change(&mut self) -> Option { - let has_room = self.children.has_room(); - (self.announced_accept_children != Some(has_room)).then(|| { - self.announced_accept_children = Some(has_room); - has_room - }) - } - - /// The upload speed the server records for us, if it has said. - #[must_use] - pub const fn own_average_speed(&self) -> Option { - self.own_average_speed - } - - /// Record what the server says our own upload speed is, and re-derive the - /// child limit from it. - pub fn set_own_average_speed(&mut self, speed: u32) { - self.own_average_speed = Some(speed); - self.apply_child_limits(); - } - - /// Record the server's parent figures (codes 83 and 84). - pub fn set_parent_min_speed(&mut self, speed: u32) { - self.parent_min_speed = speed; - self.apply_child_limits(); - } - - pub fn set_parent_speed_ratio(&mut self, ratio: u32) { - self.parent_speed_ratio = ratio; - self.apply_child_limits(); - } - - fn apply_child_limits(&mut self) { - self.children.set_limits( - self.own_average_speed, - self.parent_min_speed, - self.parent_speed_ratio, - ); - } - - /// Whether something is feeding us the distributed search stream: a - /// parent of our own, or the server relaying searches to us. Children may - /// only hang from a client that has one. - pub fn set_fed_by(&mut self, fed: bool) { - self.children.set_fed(fed); - if !fed { - self.children.drop_all(); - } - } - - /// Record what `username` says about itself. - pub fn store_peer_info( - &mut self, - username: String, - info: crate::message::peer::PeerInfo, - ) { - self.peer_infos.insert(username, info); - } - - /// What `username` last said about itself, if anything. - #[must_use] - pub fn peer_info( - &self, - username: &str, - ) -> Option { - self.peer_infos.get(username).cloned() - } - - /// Drop what we hold about `username`, so a poll after a fresh request - /// cannot report the previous answer. - pub fn invalidate_peer_info(&mut self, username: &str) { - self.peer_infos.remove(username); - } - - /// Record one folder's listing from `username`. - pub fn store_folder_contents( - &mut self, - username: String, - folder: String, - directories: Vec, - ) { - self.folder_contents.insert((username, folder), directories); - } - - /// Remove and return the listing of `folder` from `username`, if it has - /// arrived. - pub fn take_folder_contents( - &mut self, - username: &str, - folder: &str, - ) -> Option> { - self.folder_contents - .remove(&(username.to_string(), folder.to_string())) - } - - /// Who may enter the private room `room`, as last reported. - #[must_use] - pub fn private_room_members(&self, room: &str) -> Vec { - self.private_room_members - .get(room) - .cloned() - .unwrap_or_default() - } - - /// Who runs the private room `room`, as last reported. - #[must_use] - pub fn private_room_operators(&self, room: &str) -> Vec { - self.private_room_operators - .get(room) - .cloned() - .unwrap_or_default() - } - - /// The private rooms we belong to, in a stable order. - #[must_use] - pub fn private_rooms(&self) -> Vec { - let mut rooms: Vec = - self.private_room_members.keys().cloned().collect(); - rooms.sort(); - rooms - } - - /// The ticker board of `room` as last reported by the server. - #[must_use] - pub fn room_tickers(&self, room: &str) -> Vec { - self.room_tickers.get(room).cloned().unwrap_or_default() - } - - /// Record a recommendations reply; `global` selects the server-wide set. - pub fn apply_recommendations( - &mut self, - global: bool, - recommended: Vec, - unrecommended: Vec, - ) { - let slot = if global { - &mut self.global_recommendations - } else { - &mut self.recommendations - }; - *slot = Some((recommended, unrecommended)); - } - - /// The last recommendations reply, or `None` until one arrives. - #[must_use] - pub fn recommendations( - &self, - global: bool, - ) -> Option<(Vec, Vec)> { - if global { - self.global_recommendations.clone() - } else { - self.recommendations.clone() - } - } - - pub fn apply_item_recommendations( - &mut self, - item: String, - recommendations: Vec, - ) { - self.item_recommendations.insert(item, recommendations); - } - - #[must_use] - pub fn item_recommendations(&self, item: &str) -> Vec { - self.item_recommendations - .get(item) - .cloned() - .unwrap_or_default() - } - - pub fn apply_similar_users(&mut self, users: Vec) { - self.similar_users = users; - } - - #[must_use] - pub fn similar_users(&self) -> Vec { - self.similar_users.clone() - } - - pub fn apply_item_similar_users( - &mut self, - item: String, - usernames: Vec, - ) { - self.item_similar_users.insert(item, usernames); - } - - #[must_use] - pub fn item_similar_users(&self, item: &str) -> Vec { - self.item_similar_users - .get(item) - .cloned() - .unwrap_or_default() - } - - pub fn apply_user_interests(&mut self, interests: UserInterests) { - self.user_interests - .insert(interests.username.clone(), interests); - } - - #[must_use] - pub fn user_interests(&self, username: &str) -> Option { - self.user_interests.get(username).cloned() - } - - /// Record the phrases the server excludes (code 160), lowercased here so - /// the per-file check is a plain `contains` on a lowercased path. - pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { - self.excluded_search_phrases = - phrases.iter().map(|p| p.to_lowercase()).collect(); - } - - /// The phrases the server refuses to search for. - #[must_use] - pub fn excluded_search_phrases(&self) -> Vec { - self.excluded_search_phrases.clone() - } - - /// Forget any interests cached for `username`, so a poll after a fresh - /// request cannot mistake the previous answer for this one. - pub fn invalidate_user_interests(&mut self, username: &str) { - self.user_interests.remove(username); - } - - /// Record a `GetUserStatus` reply, merging it with any statistics already - /// received for that user. - pub fn apply_user_status( - &mut self, - username: String, - status: u32, - privileged: bool, - ) { - self.user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username)) - .presence = Some(UserPresence { - status: UserStatus::from_code(status), - privileged, - }); - } - - /// Forget what we know about `username`, so the next poll reports the - /// answer to the request being made now rather than the previous one. - pub fn invalidate_user_info(&mut self, username: &str) { - self.user_info.remove(username); - } - - /// Record a `GetUserStats` reply, merging it with any status already - /// received for that user. - pub fn apply_user_stats( - &mut self, - username: String, - average_speed: u32, - shared_files: u32, - shared_folders: u32, - ) { - self.user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username)) - .stats = Some(UserStats { - average_speed, - shared_files, - shared_folders, - }); - } - - /// Record that we are now watching `username`. - pub fn add_watched_user(&mut self, username: &str) { - self.watched_users.insert(username.to_string()); - } - - /// Forget a watch, dropping any snapshot we held for that user so a later - /// re-watch reports a fresh answer rather than a stale one. - pub fn remove_watched_user(&mut self, username: &str) { - self.watched_users.remove(username); - self.user_info.remove(username); - } - - /// Everyone we are currently watching, sorted by name. - #[must_use] - pub fn watched_users(&self) -> Vec { - let mut users: Vec = - self.watched_users.iter().cloned().collect(); - users.sort(); - users - } - - /// Record the initial snapshot from a `WatchUser` reply. A username the - /// server does not know carries no stats and is dropped from the watch - /// list, since the server will never push anything for it. - pub fn apply_watched_user( - &mut self, - username: String, - exists: bool, - status: Option, - average_speed: Option, - shared_files: Option, - shared_folders: Option, - ) { - if !exists { - self.watched_users.remove(&username); - return; - } - if let Some(status) = status { - let entry = self - .user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username.clone())); - // The watch reply carries no privileged flag, so keep whatever a - // previous GetUserStatus told us rather than asserting `false`. - let privileged = - entry.presence.as_ref().is_some_and(|p| p.privileged); - entry.presence = Some(UserPresence { - status: UserStatus::from_code(status), - privileged, - }); - } - if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = - (average_speed, shared_files, shared_folders) - { - self.apply_user_stats( - username, - average_speed, - shared_files, - shared_folders, - ); - } - } - - /// What the server has said about `username` so far. - #[must_use] - pub fn user_info(&self, username: &str) -> Option { - self.user_info.get(username).cloned() - } - - /// Who is currently in `room`, sorted, or empty when we are not in it. - #[must_use] - pub fn room_members(&self, room: &str) -> Vec { - self.room_members.get(room).cloned().unwrap_or_default() - } - - /// Record the per-member statistics of a room we just joined, replacing - /// any previous snapshot for it. - pub fn apply_room_member_stats( - &mut self, - room: String, - stats: Vec, - ) { - self.room_member_stats.insert(room, stats); - } - - /// What the server reported about the members of `room`, sorted by name, - /// or empty for a room we have not joined. - #[must_use] - pub fn room_member_stats(&self, room: &str) -> Vec { - let mut stats = self - .room_member_stats - .get(room) - .cloned() - .unwrap_or_default(); - stats.sort_by(|a, b| a.username.cmp(&b.username)); - stats - } - - /// The latest snapshot of the public chat-room list. - #[must_use] - pub fn room_list(&self) -> Vec { - self.room_list.clone() - } - - /// Remove and return all chat-room events received since the last call. - #[must_use] - pub fn take_room_events(&mut self) -> Vec { - std::mem::take(&mut self.room_events) - } - - /// Cache a peer's listen address learned from a GetPeerAddress response. - pub fn cache_peer_address( - &mut self, - username: &str, - host: String, - port: u32, - ) { - self.peer_addresses - .insert(username.to_string(), (host, port)); - } - - /// The cached listen address for `username`, if known. - #[must_use] - pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { - self.peer_addresses.get(username).cloned() - } - - /// Queue a peer message to send once a control connection to `username` is up. - pub fn queue_peer_message( - &mut self, - username: &str, - message: crate::message::Message, - ) { - let entry = self - .pending_peer_messages - .entry(username.to_string()) - .or_insert_with(|| (Instant::now(), Vec::new())); - entry.0 = Instant::now(); - entry.1.push(message); - } - - /// Remove and return the messages queued for `username`. - pub fn take_peer_messages( - &mut self, - username: &str, - ) -> Vec { - self.pending_peer_messages - .remove(username) - .map(|(_, messages)| messages) - .unwrap_or_default() - } - - /// Drop peer messages nobody could deliver within - /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. - pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { - self.pending_peer_messages.retain(|_, (since, _)| { - now.duration_since(*since) < PENDING_PEER_TTL - }); - } - - /// Store a shared-file listing received from browsing `username`. - pub fn store_browse_result( - &mut self, - username: String, - directories: Vec, - ) { - self.pending_browses.remove(&username); - self.browse_results.insert(username, directories); - } - - pub(crate) fn mark_browse_pending(&mut self, username: &str) { - let now = Instant::now(); - self.pending_browses.retain(|_, deadline| *deadline > now); - self.pending_browses - .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); - } - - /// Remove and return the shared-file listing browsed from `username`. - pub fn take_browse_result( - &mut self, - username: &str, - ) -> Option> { - self.browse_results.remove(username) - } - - /// Remember that a server-brokered connection to `username` is pending under - /// `token`; the peer will quote it back in a PierceFirewall. - pub fn add_pending_connect(&mut self, token: u32, username: String) { - self.pending_connect_tokens - .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); - } - - /// Resolve and consume the peer expected for a brokered connection `token`. - pub fn take_pending_connect(&mut self, token: u32) -> Option { - self.pending_connect_tokens - .remove(&token) - .map(|(username, _)| username) - } - - pub fn take_expired_connects(&mut self, now: Instant) -> Vec { - let mut expired = Vec::new(); - self.pending_connect_tokens - .retain(|_, (username, deadline)| { - if now >= *deadline { - expired.push(std::mem::take(username)); - return false; - } - true - }); - expired - } - - pub(crate) fn protected_peers(&self) -> HashSet { - let mut protected: HashSet = self - .downloads - .list() - .iter() - .filter(|d| { - matches!( - d.status, - DownloadStatus::Queued - | DownloadStatus::InProgress { .. } - | DownloadStatus::Paused { .. } - ) - }) - .map(|d| d.username.clone()) - .collect(); - protected - .extend(self.active_uploads.values().map(|u| u.username.clone())); - protected.extend(self.uploads.values().map(|j| j.downloader.clone())); - protected - .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); - protected.extend(self.pending_serves.keys().cloned()); - protected.extend(self.pending_peer_messages.keys().cloned()); - let now = Instant::now(); - protected.extend( - self.pending_browses - .iter() - .filter(|(_, deadline)| **deadline > now) - .map(|(username, _)| username.clone()), - ); - protected - } - - /// Record a private message received from another user. - pub fn push_private_message(&mut self, message: UserMessage) { - self.private_messages.push(message); - } - - /// Remove and return all buffered private messages. - pub fn take_private_messages(&mut self) -> Vec { - std::mem::take(&mut self.private_messages) - } -} pub struct Client { enable_listen: bool, listen_port: u16, @@ -1592,6 +847,7 @@ impl Client { mod children; mod connection; +mod context; mod distributed; mod downloads; mod operations; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations/mod.rs similarity index 54% rename from soulseek-rs-lib/src/client/operations.rs rename to soulseek-rs-lib/src/client/operations/mod.rs index f65684a..d8f9905 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations/mod.rs @@ -8,6 +8,11 @@ use super::{ use crate::message::server::MessageFactory; use crate::peer::DownloadError; +mod peers; +mod search; +mod transfers; +mod tree; + const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); /// How often to ask the peers holding our queued downloads where they sit @@ -98,118 +103,24 @@ impl Client { } } } - ClientOperation::PeerDisconnected(id, username, error) => { - // Scope the read guard: process_failed_uploads - // below acquires a write lock on the same - // RwLock, which would self-deadlock the entire - // client ops loop if this read guard were still - // held on this thread. Evict only if this exact - // actor still occupies the slot, so a replaced - // actor's shutdown can't remove its successor. - { - let context = match client_context.read_safe() { - Ok(c) => c, - Err(e) => { - error!( - "[client] PeerDisconnected read: {}", - e - ); - continue; - } - }; - if let Some(ref registry) = context.peer_registry - && let Some(handle) = - registry.remove_peer_if(&username, id) - { - let _ = handle.stop(); - } - } - // Only an error is evidence the peer is gone. A clean - // close — our idle reaper, or a remote client tidying - // an idle socket while it waits in our queue — must - // not throw away everything that peer has queued: the - // connection comes back, the queue cannot. A departed - // peer wedging uploads shut is still covered: its - // erroring transfer fails within the socket deadlines - // and the error branch here frees its slots. - if let Some(error) = error { - warn!( - "[client] Peer {} disconnected with error: {:?}", - username, error - ); - Self::process_failed_uploads( - client_context.clone(), - &username, - None, - ); - Self::release_upload_slots( - &client_context, - &username, - ); - } + operation @ (ClientOperation::PeerDisconnected(..) + | ClientOperation::PeerConnectFailed(..) + | ClientOperation::ChildConnected { .. }) => { + Self::on_peers( + operation, + &client_context, + &own_username, + ); } - ClientOperation::DownloadFromPeer(token, peer, allowed) => { - let maybe_download = match client_context.write_safe() { - Ok(mut ctx) => ctx - .downloads - .claim_for_peer(token, &peer.username), - Err(e) => { - error!( - "[client] DownloadFromPeer write: {}", - e - ); - continue; - } - }; - trace!( - "[client] DownloadFromPeer token: {} peer: {:?}", - token, peer + operation @ (ClientOperation::DownloadFromPeer(..) + | ClientOperation::UpdateDownloadTokens(..) + | ClientOperation::StartUpload { .. } + | ClientOperation::QueueUpload { .. }) => { + Self::on_transfers( + operation, + &client_context, + &own_username, ); - let Some(download) = maybe_download else { - debug!( - "[client] transfer token {} is missing, belongs \ - to another peer, or is already claimed; ignoring", - token, - ); - continue; - }; - - let own_username = own_username.clone(); - let client_context = client_context.clone(); - - thread::spawn(move || { - let download_peer = DownloadPeer::new( - download.username.clone(), - peer.host.clone(), - peer.port, - token, - allowed, - own_username, - ); - match download_peer.download_file( - client_context, - Some(download.clone()), - None, - ) { - Ok((download, filename)) => { - info!( - "Successfully downloaded {} bytes to {}", - download.size, filename - ); - } - Err(DownloadError::Cancelled) => {} - Err(e) => { - error!( - "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", - download.filename, - peer.host, - peer.port, - download.token, - e - ); - } - } - }); } ClientOperation::GetPeerAddressResponse { username, @@ -310,78 +221,6 @@ impl Client { ); } } - ClientOperation::UpdateDownloadTokens( - transfer, - username, - ) => { - let mut context = match client_context.write_safe() { - Ok(c) => c, - Err(e) => { - error!( - "[client] UpdateDownloadTokens write: {}", - e - ); - continue; - } - }; - - let download_to_update = - context.get_downloads().iter().find_map(|d| { - if d.username == username - && d.filename == transfer.filename - { - Some((d.token, d.clone())) - } else { - None - } - }); - - let cancelled = download_to_update - .as_ref() - .is_some_and(|(_, d)| { - matches!(d.status, DownloadStatus::Cancelled) - }); - if !cancelled - && let Some((old_token, download)) = - download_to_update - { - trace!( - "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", - transfer - ); - - context.add_download(Download { - token: transfer.token, - size: transfer.size, - ..download - }); - context.remove_download(old_token); - } - - // Only now invite the file connection: it is - // matched by this token, which is recorded as - // of the line above. Answering any earlier - // races the peer's connection against our own - // bookkeeping. - let registry = context.peer_registry.clone(); - drop(context); - if let Some(registry) = registry { - let response = if cancelled { - MessageFactory::build_transfer_denial_message( - transfer.token, - "Cancelled", - ) - } else { - MessageFactory::build_transfer_response_message( - transfer, - ) - }; - let _ = registry.send_to_peer( - &username, - PeerMessage::SendMessage(response), - ); - } - } ClientOperation::UploadFailed(username, filename) => { Self::process_failed_uploads( client_context.clone(), @@ -583,138 +422,12 @@ impl Client { } } } - ClientOperation::IncomingSearch { - username, - token, - query, - from_parent, - } => { - // Every search we receive comes from the tree: from - // our parent, or from the server acting as one. That - // is what qualifies us to carry children at all — - // Nicotine+ refuses them until something feeds it — - // and passing the search down is the whole duty. - let (capacity_changed, answer) = match client_context - .write_safe() - { - Ok(mut ctx) => { - let was_fed = ctx.children.is_fed(); - let before = ctx.children.len(); - ctx.set_fed_by(true); - if !from_parent { - // A parent's search was passed down where - // it arrived; this is the server's. - ctx.children.broadcast_search( - &username, token, &query, - ); - } - // Answering costs a scan of everything we - // share, so the budget applies to searches - // from the tree. The server's own are few. - let answer = !from_parent - || ctx.leaf.admit_search(Instant::now()); - // A child whose socket has gone is dropped by - // that write, which frees a slot the server - // should hear about. - ( - !was_fed || ctx.children.len() != before, - answer, - ) - } - Err(_) => (false, false), - }; - if capacity_changed { - Self::announce_child_capacity(&client_context); - } - if !answer { - continue; - } - - // Don't answer our own distributed search. - if username == own_username { - continue; - } - let response = match client_context.read_safe() { - Ok(ctx) => build_search_response( - &ctx.shares, - &own_username, - token, - &query, - ctx.has_free_upload_slot(), - ctx.last_upload_speed, - ctx.upload_queue.len() as u32, - &ctx.excluded_search_phrases, - ), - Err(e) => { - error!("[client] IncomingSearch read: {}", e); - continue; - } - }; - let Some(message) = response else { - continue; // no matching shares - }; - - // Deliver to the searcher: send now if we have a - // control connection, else open one and queue. - let (connected, registry, server_sender) = - match client_context.read_safe() { - Ok(ctx) => ( - ctx.peer_registry - .as_ref() - .is_some_and(|r| r.contains(&username)), - ctx.peer_registry.clone(), - ctx.server_sender.clone(), - ), - Err(_) => continue, - }; - if connected { - if let Some(registry) = registry { - let _ = registry.send_to_peer( - &username, - PeerMessage::SendMessage(message), - ); - } - } else { - if let Ok(mut ctx) = client_context.write_safe() { - ctx.queue_peer_message(&username, message); - } - if let Some(sender) = server_sender { - let _ = sender.send( - ServerMessage::GetPeerAddress(username), - ); - } - } - } - ClientOperation::QueueUpload { - requester_key, - filename, - } => { - // The peer served next may not be this one. - match client_context.write_safe() { - Ok(mut ctx) => { - let Some(file) = ctx.shares.get(&filename) - else { - debug!( - "[client] QueueUpload for unknown file {}", - filename - ); - continue; - }; - let size = file.size; - let real_path = file.real_path.clone(); - ctx.enqueue_upload( - &requester_key, - &filename, - real_path, - size, - ); - } - Err(e) => { - error!("[client] QueueUpload write: {}", e); - continue; - } - } - Self::pump_upload_queue(&client_context); + operation @ ClientOperation::IncomingSearch { .. } => { + Self::on_search( + operation, + &client_context, + &own_username, + ); } ClientOperation::PlaceInQueueRequested { requester_key, @@ -760,99 +473,16 @@ impl Client { ); } } - ClientOperation::ParentBranchLevel { - parent, - link, - level, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.branch_level(&parent, link, level) - { - super::distributed::announce_move( - &mut ctx, &branch, true, - ); - } + operation @ (ClientOperation::ParentSearch { .. } + | ClientOperation::ParentBranchLevel { + .. } - ClientOperation::ParentBranchRoot { - parent, - link, - root, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.branch_root(&parent, link, &root) - { - super::distributed::announce_move( - &mut ctx, &branch, true, - ); - } + | ClientOperation::ParentBranchRoot { + .. } - ClientOperation::ParentSearch { - parent, - link, - username, - token, - query, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.search_from(&parent, link) - { - super::distributed::announce_move( - &mut ctx, &branch, true, - ); - } - if ctx.leaf.is_parent(&parent, link) { - // The tree's stream is not ours to throttle: every - // search goes down to our children. The budget - // below bounds only the share scan we do for our - // own reply. - ctx.children - .broadcast_search(&username, token, &query); - if let Some(ops) = &ctx.operations { - let _ = - ops.send(ClientOperation::IncomingSearch { - username, - token, - query, - from_parent: true, - }); - } - } - } - ClientOperation::ParentClosed { parent, link } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if ctx.leaf.closed(&parent, link) { - let branch = ctx.leaf.branch(); - super::distributed::announce_move( - &mut ctx, &branch, false, - ); - } - } - // Also what a fresh login means: whatever tree we hung - // from belongs to the old session. - ClientOperation::ResetDistributed => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - ctx.leaf.reset(); - // A fresh session feeds us nothing until the tree (or - // the server) starts again, so the children we were - // carrying are let go rather than left starving. - ctx.set_fed_by(false); - let branch = ctx.leaf.branch(); - super::distributed::announce_move( - &mut ctx, &branch, false, - ); + | ClientOperation::ParentClosed { .. } + | ClientOperation::ResetDistributed) => { + Self::on_tree(operation, &client_context); } ClientOperation::PrivilegedUsers(users) => { if let Ok(mut ctx) = client_context.write_safe() { @@ -911,51 +541,6 @@ impl Client { ctx.apply_user_interests(interests); } } - ClientOperation::ChildConnected { username, stream } => { - // A peer wants to hang from us. Take it on if this - // client serves children and has room, then tell it - // where our branch sits so it can report its own - // place; otherwise the socket is dropped here and the - // peer looks for another parent. - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if !ctx.children.has_room() { - continue; - } - // A child that stops reading must not wedge us: the - // relay writes under the context lock, so the write - // is bounded and a child that hits it loses its slot. - if stream.set_nodelay(true).is_err() - || stream - .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) - .is_err() - { - continue; - } - if username == own_username { - continue; // we cannot hang from ourselves - } - if ctx.children.accept(&username, stream) { - let branch = ctx.leaf.branch(); - ctx.children.send_stance_to( - &username, - &branch.root, - branch.level, - ); - debug!( - "[distributed] carrying {} children", - ctx.children.len() - ); - // The server stops offering us to peers once we - // are full, and starts again when one leaves. - let full = !ctx.children.has_room(); - drop(ctx); - if full { - Self::announce_child_capacity(&client_context); - } - } - } ClientOperation::PeerInfoReceived { username, info } => { if let Ok(mut ctx) = client_context.write_safe() { ctx.store_peer_info(username, info); @@ -1058,49 +643,6 @@ impl Client { ); } } - ClientOperation::StartUpload { token } => { - // The peer accepted our offer: resolve their - // address (from the code-9 GetPeerAddress) and - // stream the file, or queue until it resolves. - let (job_addr, downloader) = match client_context - .write_safe() - { - Ok(mut ctx) => { - ctx.mark_offer_answered(token); - let Some(job) = ctx.uploads.get(&token) else { - continue; - }; - ( - ctx.peer_address(&job.downloader), - job.downloader.clone(), - ) - } - Err(_) => continue, - }; - if let Some((host, port)) = job_addr { - Self::spawn_serve( - &client_context, - &own_username, - token, - host, - port, - ); - } else { - if let Ok(mut ctx) = client_context.write_safe() { - ctx.pending_serves - .entry(downloader.clone()) - .or_default() - .push(token); - } - if let Ok(ctx) = client_context.read_safe() - && let Some(sender) = ctx.server_sender.clone() - { - let _ = sender.send( - ServerMessage::GetPeerAddress(downloader), - ); - } - } - } ClientOperation::ShareListRequested { requester_key } => { Self::reply_to_peer( &client_context, @@ -1156,103 +698,6 @@ impl Client { ctx.store_browse_result(username, directories); } } - ClientOperation::PeerConnectFailed( - id, - username, - brokered_token, - ) => { - // Direct connect failed. A dial the server asked - // us to make is answered with CantConnectToPeer - // quoting the peer's own token: that peer is - // already waiting on the server, and asking it to - // broker the same connection back only trades one - // unreachable direction for the other. Every other - // dial falls back to the broker below. - if let Some(peer_token) = brokered_token { - let server_sender = match client_context - .write_safe() - { - Ok(ctx) => { - if let Some(handle) = - ctx.peer_registry.as_ref().and_then( - |r| r.remove_peer_if(&username, id), - ) - { - let _ = handle.stop(); - } - ctx.server_sender.clone() - } - Err(e) => { - error!( - "[client] PeerConnectFailed write: {}", - e - ); - continue; - } - }; - if let Some(sender) = server_sender { - let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( - peer_token, - &username, - ); - let _ = sender - .send(ServerMessage::SendMessage(msg)); - } - // Nothing else is coming from this peer, so - // anything queued for it fails now rather than - // waiting out its timeout. - Self::fail_queued_downloads( - &client_context, - &username, - ); - continue; - } - - // Direct connect failed: ask the server to - // broker it. Register a correlation token, then - // send ConnectToPeer so the (firewalled) peer - // connects back to our listener quoting it. - let token = next_connect_token(); - let server_sender = match client_context.write_safe() { - Ok(mut ctx) => { - // Reap the dead outbound actor so it - // stops pinning a pool worker and no - // longer shadows the brokered reconnect - // (a stale registry entry would make - // later downloads queue into a dead, - // streamless actor and hang). Identity- - // aware so a newer namesake is untouched. - if let Some(handle) = - ctx.peer_registry.as_ref().and_then(|r| { - r.remove_peer_if(&username, id) - }) - { - let _ = handle.stop(); - } - ctx.add_pending_connect( - token, - username.clone(), - ); - ctx.server_sender.clone() - } - Err(e) => { - error!( - "[client] PeerConnectFailed write: {}", - e - ); - continue; - } - }; - let Some(sender) = server_sender else { - continue; - }; - let msg = crate::message::server::MessageFactory::build_connect_to_peer( - token, - &username, - ConnectionType::P, - ); - let _ = sender.send(ServerMessage::SendMessage(msg)); - } } } }); diff --git a/soulseek-rs-lib/src/client/operations/peers.rs b/soulseek-rs-lib/src/client/operations/peers.rs new file mode 100644 index 0000000..41028d5 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/peers.rs @@ -0,0 +1,196 @@ +//! Dispatch arms about a peer connection appearing or going away. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation, + ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error, + next_connect_token, warn, +}; + +impl Client { + pub(super) fn on_peers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::PeerDisconnected(id, username, error) => { + // Scope the read guard: process_failed_uploads + // below acquires a write lock on the same + // RwLock, which would self-deadlock the entire + // client ops loop if this read guard were still + // held on this thread. Evict only if this exact + // actor still occupies the slot, so a replaced + // actor's shutdown can't remove its successor. + { + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] PeerDisconnected read: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry + && let Some(handle) = + registry.remove_peer_if(&username, id) + { + let _ = handle.stop(); + } + } + // Only an error is evidence the peer is gone. A clean + // close — our idle reaper, or a remote client tidying + // an idle socket while it waits in our queue — must + // not throw away everything that peer has queued: the + // connection comes back, the queue cannot. A departed + // peer wedging uploads shut is still covered: its + // erroring transfer fails within the socket deadlines + // and the error branch here frees its slots. + if let Some(error) = error { + warn!( + "[client] Peer {} disconnected with error: {:?}", + username, error + ); + Self::process_failed_uploads( + client_context.clone(), + &username, + None, + ); + Self::release_upload_slots(client_context, &username); + } + } + ClientOperation::PeerConnectFailed( + id, + username, + brokered_token, + ) => { + // Direct connect failed. A dial the server asked + // us to make is answered with CantConnectToPeer + // quoting the peer's own token: that peer is + // already waiting on the server, and asking it to + // broker the same connection back only trades one + // unreachable direction for the other. Every other + // dial falls back to the broker below. + if let Some(peer_token) = brokered_token { + let server_sender = match client_context.write_safe() { + Ok(ctx) => { + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + if let Some(sender) = server_sender { + let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( + peer_token, + &username, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + // Nothing else is coming from this peer, so + // anything queued for it fails now rather than + // waiting out its timeout. + Self::fail_queued_downloads(client_context, &username); + return; + } + + // Direct connect failed: ask the server to + // broker it. Register a correlation token, then + // send ConnectToPeer so the (firewalled) peer + // connects back to our listener quoting it. + let token = next_connect_token(); + let server_sender = match client_context.write_safe() { + Ok(mut ctx) => { + // Reap the dead outbound actor so it + // stops pinning a pool worker and no + // longer shadows the brokered reconnect + // (a stale registry entry would make + // later downloads queue into a dead, + // streamless actor and hang). Identity- + // aware so a newer namesake is untouched. + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.add_pending_connect(token, username.clone()); + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + let Some(sender) = server_sender else { + return; + }; + let msg = crate::message::server::MessageFactory::build_connect_to_peer( + token, + &username, + ConnectionType::P, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + ClientOperation::ChildConnected { username, stream } => { + // A peer wants to hang from us. Take it on if this + // client serves children and has room, then tell it + // where our branch sits so it can report its own + // place; otherwise the socket is dropped here and the + // peer looks for another parent. + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if !ctx.children.has_room() { + return; + } + // A child that stops reading must not wedge us: the + // relay writes under the context lock, so the write + // is bounded and a child that hits it loses its slot. + if stream.set_nodelay(true).is_err() + || stream + .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) + .is_err() + { + return; + } + if username == own_username { + return; // we cannot hang from ourselves + } + if ctx.children.accept(&username, stream) { + let branch = ctx.leaf.branch(); + ctx.children.send_stance_to( + &username, + &branch.root, + branch.level, + ); + debug!( + "[distributed] carrying {} children", + ctx.children.len() + ); + // The server stops offering us to peers once we + // are full, and starts again when one leaves. + let full = !ctx.children.has_room(); + drop(ctx); + if full { + Self::announce_child_capacity(client_context); + } + } + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/search.rs b/soulseek-rs-lib/src/client/operations/search.rs new file mode 100644 index 0000000..526633b --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/search.rs @@ -0,0 +1,118 @@ +//! The dispatch arm that answers a search reaching us from the network. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock, + RwLockExt, ServerMessage, build_search_response, error, +}; + +impl Client { + pub(super) fn on_search( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + let ClientOperation::IncomingSearch { + username, + token, + query, + from_parent, + } = operation + else { + return; + }; + { + // Every search we receive comes from the tree: from + // our parent, or from the server acting as one. That + // is what qualifies us to carry children at all — + // Nicotine+ refuses them until something feeds it — + // and passing the search down is the whole duty. + let (capacity_changed, answer) = match client_context.write_safe() { + Ok(mut ctx) => { + let was_fed = ctx.children.is_fed(); + let before = ctx.children.len(); + ctx.set_fed_by(true); + if !from_parent { + // A parent's search was passed down where + // it arrived; this is the server's. + ctx.children.broadcast_search(&username, token, &query); + } + // Answering costs a scan of everything we + // share, so the budget applies to searches + // from the tree. The server's own are few. + let answer = + !from_parent || ctx.leaf.admit_search(Instant::now()); + // A child whose socket has gone is dropped by + // that write, which frees a slot the server + // should hear about. + (!was_fed || ctx.children.len() != before, answer) + } + Err(_) => (false, false), + }; + if capacity_changed { + Self::announce_child_capacity(client_context); + } + if !answer { + return; + } + + // Don't answer our own distributed search. + if username == own_username { + return; + } + let response = match client_context.read_safe() { + Ok(ctx) => build_search_response( + &ctx.shares, + &own_username, + token, + &query, + ctx.has_free_upload_slot(), + ctx.last_upload_speed, + ctx.upload_queue.len() as u32, + &ctx.excluded_search_phrases, + ), + Err(e) => { + error!("[client] IncomingSearch read: {}", e); + return; + } + }; + let Some(message) = response else { + return; // no matching shares + }; + + // Deliver to the searcher: send now if we have a + // control connection, else open one and queue. + let (connected, registry, server_sender) = + match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)), + ctx.peer_registry.clone(), + ctx.server_sender.clone(), + ), + Err(_) => return, + }; + if connected { + if let Some(registry) = registry { + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(message), + ); + } + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.queue_peer_message(&username, message); + } + if let Some(sender) = server_sender { + let _ = + sender.send(ServerMessage::GetPeerAddress(username)); + } + } + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/transfers.rs b/soulseek-rs-lib/src/client/operations/transfers.rs new file mode 100644 index 0000000..d41fd63 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/transfers.rs @@ -0,0 +1,219 @@ +//! Dispatch arms that move bytes: what a peer allows, what it asks of us, +//! and the tokens tying the two together. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it — including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Download, DownloadError, + DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock, + RwLockExt, ServerMessage, debug, error, info, thread, trace, +}; + +impl Client { + pub(super) fn on_transfers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::DownloadFromPeer(token, peer, allowed) => { + let maybe_download = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.downloads.claim_for_peer(token, &peer.username) + } + Err(e) => { + error!("[client] DownloadFromPeer write: {}", e); + return; + } + }; + trace!( + "[client] DownloadFromPeer token: {} peer: {:?}", + token, peer + ); + let Some(download) = maybe_download else { + debug!( + "[client] transfer token {} is missing, belongs \ + to another peer, or is already claimed; ignoring", + token, + ); + return; + }; + + let client_context = client_context.clone(); + + thread::spawn(move || { + let download_peer = DownloadPeer::new( + download.username.clone(), + peer.host.clone(), + peer.port, + token, + allowed, + own_username, + ); + match download_peer.download_file( + client_context, + Some(download.clone()), + None, + ) { + Ok((download, filename)) => { + info!( + "Successfully downloaded {} bytes to {}", + download.size, filename + ); + } + Err(DownloadError::Cancelled) => {} + Err(e) => { + error!( + "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", + download.filename, + peer.host, + peer.port, + download.token, + e + ); + } + } + }); + } + ClientOperation::UpdateDownloadTokens(transfer, username) => { + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] UpdateDownloadTokens write: {}", e); + return; + } + }; + + let download_to_update = + context.get_downloads().iter().find_map(|d| { + if d.username == username + && d.filename == transfer.filename + { + Some((d.token, d.clone())) + } else { + None + } + }); + + let cancelled = + download_to_update.as_ref().is_some_and(|(_, d)| { + matches!(d.status, DownloadStatus::Cancelled) + }); + if !cancelled + && let Some((old_token, download)) = download_to_update + { + trace!( + "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", + transfer + ); + + context.add_download(Download { + token: transfer.token, + size: transfer.size, + ..download + }); + context.remove_download(old_token); + } + + // Only now invite the file connection: it is + // matched by this token, which is recorded as + // of the line above. Answering any earlier + // races the peer's connection against our own + // bookkeeping. + let registry = context.peer_registry.clone(); + drop(context); + if let Some(registry) = registry { + let response = if cancelled { + MessageFactory::build_transfer_denial_message( + transfer.token, + "Cancelled", + ) + } else { + MessageFactory::build_transfer_response_message( + transfer, + ) + }; + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(response), + ); + } + } + ClientOperation::StartUpload { token } => { + // The peer accepted our offer: resolve their + // address (from the code-9 GetPeerAddress) and + // stream the file, or queue until it resolves. + let (job_addr, downloader) = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.mark_offer_answered(token); + let Some(job) = ctx.uploads.get(&token) else { + return; + }; + ( + ctx.peer_address(&job.downloader), + job.downloader.clone(), + ) + } + Err(_) => return, + }; + if let Some((host, port)) = job_addr { + Self::spawn_serve( + client_context, + &own_username, + token, + host, + port, + ); + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.pending_serves + .entry(downloader.clone()) + .or_default() + .push(token); + } + if let Ok(ctx) = client_context.read_safe() + && let Some(sender) = ctx.server_sender.clone() + { + let _ = sender + .send(ServerMessage::GetPeerAddress(downloader)); + } + } + } + ClientOperation::QueueUpload { + requester_key, + filename, + } => { + // The peer served next may not be this one. + match client_context.write_safe() { + Ok(mut ctx) => { + let Some(file) = ctx.shares.get(&filename) else { + debug!( + "[client] QueueUpload for unknown file {}", + filename + ); + return; + }; + let size = file.size; + let real_path = file.real_path.clone(); + ctx.enqueue_upload( + &requester_key, + &filename, + real_path, + size, + ); + } + Err(e) => { + error!("[client] QueueUpload write: {}", e); + return; + } + } + Self::pump_upload_queue(client_context); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/tree.rs b/soulseek-rs-lib/src/client/operations/tree.rs new file mode 100644 index 0000000..68a4d36 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/tree.rs @@ -0,0 +1,103 @@ +//! Dispatch arms for our place in the distributed search tree. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it. + +use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt}; + +impl Client { + pub(super) fn on_tree( + operation: ClientOperation, + client_context: &Arc>, + ) { + match operation { + ClientOperation::ParentSearch { + parent, + link, + username, + token, + query, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.search_from(&parent, link) { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + if ctx.leaf.is_parent(&parent, link) { + // The tree's stream is not ours to throttle: every + // search goes down to our children. The budget + // below bounds only the share scan we do for our + // own reply. + ctx.children.broadcast_search(&username, token, &query); + if let Some(ops) = &ctx.operations { + let _ = ops.send(ClientOperation::IncomingSearch { + username, + token, + query, + from_parent: true, + }); + } + } + } + ClientOperation::ParentBranchLevel { + parent, + link, + level, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = + ctx.leaf.branch_level(&parent, link, level) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentBranchRoot { parent, link, root } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentClosed { parent, link } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if ctx.leaf.closed(&parent, link) { + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + } + // Also what a fresh login means: whatever tree we hung + // from belongs to the old session. + ClientOperation::ResetDistributed => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + ctx.leaf.reset(); + // A fresh session feeds us nothing until the tree (or + // the server) starts again, so the children we were + // carrying are let go rather than left starving. + ctx.set_fed_by(false); + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs deleted file mode 100644 index 6be8609..0000000 --- a/soulseek-rs-lib/src/client/tests.rs +++ /dev/null @@ -1,1186 +0,0 @@ -use super::*; - -fn download( - username: &str, - filename: &str, - token: u32, - status: DownloadStatus, - sender: Sender, -) -> Download { - Download { - username: username.to_string(), - filename: filename.to_string(), - token, - size: 100, - download_directory: "test".to_string(), - status, - sender, - queue_position: None, - metadata: DownloadMetadata::default(), - } -} - -#[test] -fn test_client_context_downloads() { - let mut context = ClientContext::new(); - let token = 123; - let new_token = 1234; - context.add_download(download( - "test", - "test.txt", - token, - DownloadStatus::Queued, - mpsc::channel().0, - )); - assert!(context.get_download_by_token(123).is_some()); - assert_eq!(context.get_download_tokens(), vec![123]); - assert_eq!(context.get_downloads().len(), 1); - if let Some(download) = context.get_download_by_token_mut(token) { - assert_eq!(download.token, token); - download.token = new_token; - } - assert!(context.get_download_by_token(new_token).is_some()); - assert_eq!(context.get_download_tokens(), vec![new_token]); - context.remove_download(new_token); - assert_eq!(context.get_downloads().len(), 0); - assert!(context.get_download_by_token(1234).is_none()); -} - -#[test] -fn test_client_pause_and_resume_download() { - let client = Client::new("test-user", "test-password"); - let (download_sender, download_receiver) = mpsc::channel(); - - client.context.write().unwrap().add_download(download( - "peer", - "song.mp3", - 123, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - download_sender, - )); - - assert!(client.pause_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - assert!(matches!( - download_receiver.try_recv().unwrap(), - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - - assert!(client.resume_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 0.0 - } - )); -} - -#[test] -fn download_without_a_connection_resolves_failed() { - // A client that never connected has no server handle and no peer registry, - // so it cannot open a connection to the peer: the download must resolve to - // Failed rather than hang Queued forever. - let client = Client::new("test-user", "test-password"); - let (_download, receiver) = client - .download( - "song.mp3".to_string(), - "peer".to_string(), - 100, - "test".to_string(), - ) - .expect("download() should return a handle"); - assert!(matches!( - receiver.recv_timeout(Duration::from_secs(1)), - Ok(DownloadStatus::Failed(_)) - )); -} - -#[test] -fn fail_queued_downloads_notifies_receiver_and_store() { - // When a brokered connect times out, every Queued download for the peer - // must resolve to Failed both on its channel and in the store. - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 7, - DownloadStatus::Queued, - sender, - )); - - Client::fail_queued_downloads(&client.context, "peer"); - - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(7) - .unwrap() - .status, - DownloadStatus::Failed(_) - )); -} - -#[test] -fn build_search_response_matches_shares_and_echoes_token() { - let dir = std::env::temp_dir() - .join(format!("soulseek-searchresp-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); - let shares = Shares::scan(&dir).unwrap(); - - let response = - build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) - .expect("a matching share yields a response"); - let mut decoded = - crate::message::Message::new_with_data(response.get_buffer()); - decoded.set_pointer(8); - let result = SearchResult::new_from_message(&mut decoded).unwrap(); - assert_eq!(result.username, "me"); - assert_eq!(result.token, 99); - assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); - - assert!( - build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) - .is_none() - ); - let _ = std::fs::remove_dir_all(dir); -} - -fn peer_files(count: usize) -> SearchResult { - SearchResult { - token: 1, - files: (0..count) - .map(|i| crate::types::File { - username: "bob".to_string(), - name: format!("song-{i}.mp3"), - size: 1, - attribs: std::collections::HashMap::new(), - }) - .collect(), - slots: 1, - speed: 0, - username: "bob".to_string(), - } -} - -#[test] -fn search_file_counts_cover_every_search_without_the_results() { - // The daemon polls these counts many times a second; they must come from - // a walk of the cache, never a copy of it. - let client = Client::new("u", "p"); - { - let mut context = client.context.write().unwrap(); - context.searches.insert( - "aphex twin".to_string(), - Search { - token: 1, - results: vec![peer_files(2), peer_files(1)], - }, - ); - context.searches.insert( - "nothing yet".to_string(), - Search { - token: 2, - results: Vec::new(), - }, - ); - } - - let mut counts = client.search_file_counts(); - counts.sort(); - assert_eq!( - counts, - [ - ("aphex twin".to_string(), 3), - ("nothing yet".to_string(), 0) - ] - ); -} - -#[test] -fn a_search_stops_collecting_once_it_has_enough_responses() { - // A popular query on the live network draws answers for minutes — - // nearly a million files and gigabytes of memory for one search. - // Surplus responders are dropped, not archived. - let mut search = Search { - token: 1, - results: Vec::new(), - }; - for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { - search.accept(peer_files(1)); - } - assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); -} - -#[test] -fn a_flood_of_files_fills_a_search_before_the_response_cap() { - // A handful of whales with huge matching collections must not add up - // to an unbounded set just because the responses are few. - let mut search = Search { - token: 1, - results: Vec::new(), - }; - for _ in 0..10 { - search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); - } - assert_eq!( - search.results.len(), - 2, - "two half-cap responses fill the search; the rest are dropped" - ); -} - -#[test] -fn test_client_removes_only_queued_downloads() { - let client = Client::new("test-user", "test-password"); - { - let mut context = client.context.write().unwrap(); - context.add_download(download( - "peer", - "queued.mp3", - 123, - DownloadStatus::Queued, - mpsc::channel().0, - )); - context.add_download(download( - "peer", - "active.mp3", - 456, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - mpsc::channel().0, - )); - } - - assert!(client.remove_queued_download("peer", "queued.mp3")); - assert!(!client.remove_queued_download("peer", "active.mp3")); - let context = client.context.read().unwrap(); - assert!(context.get_download_by_token(123).is_none()); - assert!(context.get_download_by_token(456).is_some()); -} - -use crate::types::{RoomEvent, UploadStatus}; -use std::time::{Duration, Instant}; - -#[test] -fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { - let mut context = ClientContext::new(); - - // Joining hands us the current membership in one event. - context.apply_room_event(RoomEvent::Joined { - room: "lobby".to_string(), - users: vec!["bob".to_string(), "alice".to_string()], - }); - assert_eq!(context.room_members("lobby"), ["alice", "bob"]); - - context.apply_room_event(RoomEvent::UserJoined { - room: "lobby".to_string(), - username: "carol".to_string(), - }); - context.apply_room_event(RoomEvent::UserLeft { - room: "lobby".to_string(), - username: "bob".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice", "carol"]); - - // Events for other rooms do not leak into this one. - context.apply_room_event(RoomEvent::UserJoined { - room: "elsewhere".to_string(), - username: "dave".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice", "carol"]); - - // A room we never joined has no roster, and leaving forgets it. - assert!(context.room_members("unknown").is_empty()); - context.apply_room_event(RoomEvent::Left { - room: "lobby".to_string(), - }); - assert!(context.room_members("lobby").is_empty()); -} - -#[test] -fn a_fresh_request_discards_the_previous_answer() { - // Without this, a poll after a second request returns the old - // snapshot immediately and the caller cannot tell stale from fresh. - let mut context = ClientContext::new(); - context.apply_user_status("alice".to_string(), 2, false); - context.apply_user_stats("alice".to_string(), 10, 20, 30); - assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); - - context.invalidate_user_info("alice"); - assert!( - context.user_info("alice").is_none(), - "a new request must not be answerable from the old reply" - ); -} - -#[test] -fn each_reply_fills_only_its_own_half() { - let mut context = ClientContext::new(); - context.apply_user_status("bob".to_string(), 1, true); - - let info = context.user_info("bob").expect("a snapshot"); - assert!(!info.is_complete(), "stats have not arrived"); - assert_eq!( - info.presence.map(|p| p.status), - Some(crate::types::UserStatus::Away) - ); - assert!(info.stats.is_none(), "must not invent statistics"); - - context.apply_user_stats("bob".to_string(), 5, 6, 7); - let info = context.user_info("bob").expect("a snapshot"); - assert!(info.is_complete()); - assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); - assert_eq!( - info.presence.map(|p| p.privileged), - Some(true), - "the earlier half must survive the merge" - ); -} - -#[test] -fn a_user_joining_twice_is_listed_once() { - let mut context = ClientContext::new(); - context.apply_room_event(RoomEvent::Joined { - room: "lobby".to_string(), - users: vec!["alice".to_string()], - }); - context.apply_room_event(RoomEvent::UserJoined { - room: "lobby".to_string(), - username: "alice".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice"]); -} - -#[test] -fn upload_speed_is_reported_only_while_running() { - let two_seconds_ago = Instant::now() - .checked_sub(Duration::from_secs(2)) - .expect("clock supports a 2s offset"); - - // 1 KiB over ~2s is ~512 B/s. - let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); - assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); - - // A finished upload reports no rate, exactly as a finished download - // does, so the Speed column renders "-" rather than a stale figure. - for status in [ - UploadStatus::Completed, - UploadStatus::Cancelled, - UploadStatus::Failed("nope".to_string()), - ] { - let rate = upload_speed(&status, 1024, two_seconds_ago); - assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); - } - - // A just-started upload must not divide by a zero elapsed time. - let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); - assert!( - rate.is_finite() && rate.abs() < f64::EPSILON, - "unexpected rate {rate}" - ); -} - -// A clean disconnect — the idle reaper, or a remote client tidying an idle -// socket while it waits in our queue — must keep the peer's queued uploads; -// only an error disconnect is evidence the peer is gone. -#[test] -fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { - let client = Client::new("test-user", "test-password"); - let (ops, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "me".to_string(), - ); - - client.context.write().unwrap().enqueue_upload( - "amy", - "@@share\\f.mp3", - std::path::PathBuf::from("/tmp/f.mp3"), - 4096, - ); - - ops.send(ClientOperation::PeerDisconnected( - 1, - "amy".to_string(), - None, - )) - .unwrap(); - // The loop is serial, so once this fence op is visible the disconnect - // before it has been handled. - ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while client.own_privilege_seconds() != Some(7) { - assert!(Instant::now() < deadline, "ops loop never caught up"); - thread::sleep(Duration::from_millis(10)); - } - assert_eq!( - client - .context - .read() - .unwrap() - .place_in_queue("amy", "@@share\\f.mp3"), - Some(1), - "a clean disconnect must keep the queue" - ); - - ops.send(ClientOperation::PeerDisconnected( - 1, - "amy".to_string(), - Some(crate::error::SoulseekRs::NotConnected), - )) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let place = client - .context - .read() - .unwrap() - .place_in_queue("amy", "@@share\\f.mp3"); - if place.is_none() { - break; - } - assert!( - Instant::now() < deadline, - "an error disconnect must drop the queue" - ); - thread::sleep(Duration::from_millis(10)); - } -} - -#[test] -fn a_pending_connect_expires_after_the_broker_timeout() { - let mut context = ClientContext::new(); - context.add_pending_connect(7, "ghost".to_string()); - - assert!(context.take_expired_connects(Instant::now()).is_empty()); - - let after_deadline = - Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); - assert_eq!( - context.take_expired_connects(after_deadline), - vec!["ghost".to_string()] - ); - assert!(context.take_pending_connect(7).is_none()); -} - -#[test] -fn protected_peers_covers_downloads_uploads_and_pending_serves() { - let mut context = ClientContext::new(); - context.add_download(download( - "downloader", - "song.mp3", - 1, - DownloadStatus::Queued, - mpsc::channel().0, - )); - context.add_download(download( - "done", - "old.mp3", - 2, - DownloadStatus::Completed, - mpsc::channel().0, - )); - context - .pending_serves - .insert("waiting".to_string(), vec![9]); - context.mark_browse_pending("browsed"); - - let protected = context.protected_peers(); - assert!(protected.contains("downloader")); - assert!(protected.contains("waiting")); - assert!(protected.contains("browsed")); - assert!(!protected.contains("done")); - - context.store_browse_result("browsed".to_string(), Vec::new()); - assert!(!context.protected_peers().contains("browsed")); -} - -#[test] -fn an_expired_broker_connect_fails_the_queued_downloads() { - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - { - let mut ctx = client.context.write().unwrap(); - ctx.add_download(download( - "ghost", - "f.mp3", - 7, - DownloadStatus::Queued, - sender, - )); - ctx.pending_connect_tokens - .insert(7, ("ghost".to_string(), Instant::now())); - } - let (_ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "u".to_string(), - ); - - let status = receiver.recv_timeout(Duration::from_secs(5)); - assert!( - matches!(status, Ok(DownloadStatus::Failed(_))), - "the sweep must fail the queued download, got {status:?}" - ); -} - -#[test] -fn a_replayed_transfer_response_does_not_start_a_second_transfer() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - let download_dir = std::env::temp_dir() - .join(format!("soulseek-replayed-transfer-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&download_dir); - std::fs::create_dir_all(&download_dir).unwrap(); - - let client = Client::new("u", "p"); - let (sender, _receiver) = mpsc::channel(); - let mut queued = - download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); - queued.download_directory = download_dir.display().to_string(); - client.context.write().unwrap().add_download(queued); - - let (ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "u".to_string(), - ); - - let peer = Peer::new( - "peer".to_string(), - ConnectionType::F, - "127.0.0.1".to_string(), - port, - None, - 0, - 0, - 0, - ); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) - .unwrap(); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer, true)) - .unwrap(); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut connections = 0; - while Instant::now() < deadline { - match listener.accept() { - Ok(_) => { - connections += 1; - if connections == 1 { - thread::sleep(Duration::from_millis(500)); - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - if connections >= 1 { - break; - } - thread::sleep(Duration::from_millis(20)); - } - Err(_) => break, - } - } - assert_eq!( - connections, 1, - "a replayed TransferResponse must not dial the peer again" - ); - let _ = std::fs::remove_dir_all(download_dir); -} - -#[test] -fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let client = Client::new("u", "p"); - let (sender, _receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Cancelled, - sender, - )); - - let (ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "u".to_string(), - ); - let peer = Peer::new( - "peer".to_string(), - ConnectionType::F, - "127.0.0.1".to_string(), - port, - None, - 0, - 0, - 0, - ); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer, true)) - .unwrap(); - - thread::sleep(Duration::from_millis(500)); - assert!( - matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), - "a cancelled download must not dial the peer" - ); -} - -#[test] -fn cancel_download_marks_the_store_and_answers_whether_it_matched() { - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Queued, - sender, - )); - - assert!(client.cancel_download("peer", "f.mp3")); - assert!(!client.cancel_download("peer", "f.mp3")); - assert!(!client.cancel_download("peer", "other.mp3")); - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); - assert!(matches!( - client.get_all_downloads()[0].status, - DownloadStatus::Cancelled - )); -} - -#[test] -fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { - use crate::types::{RoomUserStats, UserStatus}; - let stat = |username: &str| RoomUserStats { - username: username.to_string(), - status: UserStatus::Online, - average_speed: 1, - shared_files: 2, - shared_folders: 3, - slots_full: true, - country: None, - }; - - let mut context = ClientContext::new(); - context.apply_room_member_stats( - "jazz".to_string(), - vec![stat("carol"), stat("alice")], - ); - - let names: Vec = context - .room_member_stats("jazz") - .into_iter() - .map(|s| s.username) - .collect(); - assert_eq!(names, vec!["alice", "carol"]); - assert!(context.room_member_stats("unjoined").is_empty()); -} - -#[test] -fn rejoining_replaces_the_previous_member_stats() { - use crate::types::{RoomUserStats, UserStatus}; - let stat = |username: &str| RoomUserStats { - username: username.to_string(), - status: UserStatus::Online, - average_speed: 1, - shared_files: 2, - shared_folders: 3, - slots_full: false, - country: None, - }; - - let mut context = ClientContext::new(); - context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); - context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); - - let stats = context.room_member_stats("jazz"); - assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); - assert_eq!(stats[0].username, "bob"); -} - -#[test] -fn a_watch_reply_fills_both_halves_of_the_snapshot() { - let mut context = ClientContext::new(); - context.add_watched_user("alice"); - context.apply_watched_user( - "alice".to_string(), - true, - Some(2), - Some(1024), - Some(20), - Some(3), - ); - - let info = context.user_info("alice").expect("a snapshot"); - assert!(info.is_complete(), "a watch reply carries status and stats"); - assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); - assert_eq!(context.watched_users(), vec!["alice".to_string()]); -} - -#[test] -fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { - // WatchUser has no privileged field, so it must not overwrite what - // GetUserStatus already told us with a fabricated `false`. - let mut context = ClientContext::new(); - context.apply_user_status("bob".to_string(), 1, true); - context.apply_watched_user( - "bob".to_string(), - true, - Some(2), - Some(1), - Some(2), - Some(3), - ); - - let info = context.user_info("bob").expect("a snapshot"); - assert_eq!(info.presence.map(|p| p.privileged), Some(true)); -} - -#[test] -fn watching_an_unknown_user_drops_them_from_the_list() { - // The server will never push status for a name it does not know, so - // keeping it in the watch list would show a permanently blank row. - let mut context = ClientContext::new(); - context.add_watched_user("ghost"); - context.apply_watched_user( - "ghost".to_string(), - false, - None, - None, - None, - None, - ); - - assert!(context.watched_users().is_empty()); - assert!(context.user_info("ghost").is_none()); -} - -#[test] -fn unwatching_forgets_the_users_snapshot() { - let mut context = ClientContext::new(); - context.add_watched_user("alice"); - context.apply_user_status("alice".to_string(), 2, false); - - context.remove_watched_user("alice"); - assert!(context.watched_users().is_empty()); - assert!( - context.user_info("alice").is_none(), - "a later re-watch must report a fresh answer" - ); -} - -#[test] -fn watched_users_are_listed_in_a_stable_order() { - let mut context = ClientContext::new(); - context.add_watched_user("carol"); - context.add_watched_user("alice"); - context.add_watched_user("bob"); - assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); -} - -// A reply queued for a searcher the server cannot place waits a minute, -// not forever: every such search would otherwise pin a map entry and a -// protected slot in the peer registry. -#[test] -fn a_peer_message_nobody_could_deliver_expires() { - let mut ctx = ClientContext::for_user("me"); - ctx.queue_peer_message("ghost", crate::message::Message::new()); - ctx.expire_pending_peer_messages(Instant::now()); - assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); - - ctx.queue_peer_message("ghost", crate::message::Message::new()); - ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); - assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); -} - -#[test] -fn a_new_ticker_replaces_that_users_previous_one() { - // The server treats a user's ticker as singular: a second one from the - // same user supersedes the first rather than stacking beside it. - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::Tickers { - room: "jazz".into(), - tickers: vec![ - RoomTicker { - username: "alice".into(), - ticker: "first".into(), - }, - RoomTicker { - username: "bob".into(), - ticker: "bobs".into(), - }, - ], - }); - ctx.apply_room_event(RoomEvent::TickerAdded { - room: "jazz".into(), - username: "alice".into(), - ticker: "second".into(), - }); - - let board = ctx.room_tickers("jazz"); - assert_eq!(board.len(), 2); - assert_eq!( - board.iter().find(|t| t.username == "alice").unwrap().ticker, - "second" - ); -} - -#[test] -fn a_removed_ticker_leaves_the_rest_of_the_board() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::Tickers { - room: "jazz".into(), - tickers: vec![ - RoomTicker { - username: "alice".into(), - ticker: "a".into(), - }, - RoomTicker { - username: "bob".into(), - ticker: "b".into(), - }, - ], - }); - ctx.apply_room_event(RoomEvent::TickerRemoved { - room: "jazz".into(), - username: "alice".into(), - }); - - let board = ctx.room_tickers("jazz"); - assert_eq!(board.len(), 1); - assert_eq!(board[0].username, "bob"); -} - -#[test] -fn a_global_message_is_queued_without_touching_room_membership() { - // The global feed carries messages from rooms we have not joined; they - // must not invent a roster for those rooms. - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::GlobalMessage { - room: "elsewhere".into(), - username: "alice".into(), - message: "hi".into(), - }); - assert!(ctx.room_members("elsewhere").is_empty()); - assert_eq!(ctx.take_room_events().len(), 1); -} - -#[test] -fn own_and_global_recommendations_are_kept_apart() { - let mut ctx = ClientContext::new(); - ctx.apply_recommendations( - false, - vec![Recommendation { - item: "jazz".into(), - rating: 2, - }], - Vec::new(), - ); - ctx.apply_recommendations( - true, - vec![Recommendation { - item: "pop".into(), - rating: 9, - }], - Vec::new(), - ); - - assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); - assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); -} - -#[test] -fn asking_again_about_a_users_interests_drops_the_previous_answer() { - let mut ctx = ClientContext::new(); - ctx.apply_user_interests(UserInterests { - username: "alice".into(), - likes: vec!["jazz".into()], - hates: Vec::new(), - }); - assert!(ctx.user_interests("alice").is_some()); - - ctx.invalidate_user_interests("alice"); - assert!( - ctx.user_interests("alice").is_none(), - "a stale answer must not be mistaken for the next one" - ); -} - -#[test] -fn a_private_room_roster_tracks_who_is_added_and_removed() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::PrivateMembers { - room: "club".into(), - users: vec!["bob".into(), "alice".into(), "bob".into()], - }); - assert_eq!( - ctx.private_room_members("club"), - vec!["alice".to_string(), "bob".to_string()], - "a roster is sorted and free of repeats" - ); - - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "carol".into(), - members: true, - added: true, - }); - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "alice".into(), - members: true, - added: false, - }); - assert_eq!( - ctx.private_room_members("club"), - vec!["bob".to_string(), "carol".to_string()] - ); - - // Operators are a separate roster in the same room. - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "bob".into(), - members: false, - added: true, - }); - assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); - assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); -} - -#[test] -fn revoked_membership_drops_the_room_we_can_no_longer_see() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::PrivateMembers { - room: "club".into(), - users: vec!["alice".into()], - }); - ctx.apply_room_event(RoomEvent::PrivateOperators { - room: "club".into(), - users: vec!["alice".into()], - }); - - ctx.apply_room_event(RoomEvent::OwnStandingChanged { - room: "club".into(), - members: true, - granted: false, - }); - assert!(ctx.private_rooms().is_empty()); - assert!(ctx.private_room_operators("club").is_empty()); -} - -#[test] -fn losing_operatorship_keeps_the_roster_we_can_still_see() { - // Demotion does not blind us: we are still a member, and the room's other - // operators are still ours to show. The server narrates our own removal - // from that roster separately (code 144). - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::PrivateMembers { - room: "club".into(), - users: vec!["alice".into()], - }); - ctx.apply_room_event(RoomEvent::PrivateOperators { - room: "club".into(), - users: vec!["alice".into(), "bob".into()], - }); - - ctx.apply_room_event(RoomEvent::OwnStandingChanged { - room: "club".into(), - members: false, - granted: false, - }); - assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); - assert_eq!( - ctx.private_room_operators("club"), - vec!["alice".to_string(), "bob".to_string()], - "the other operators are still there to show" - ); - - ctx.apply_room_event(RoomEvent::PrivateRosterChanged { - room: "club".into(), - username: "alice".into(), - members: false, - added: false, - }); - assert_eq!( - ctx.private_room_operators("club"), - vec!["bob".to_string()], - "only the demotion the server narrates removes us" - ); -} - -#[test] -fn a_file_the_server_excludes_is_left_out_of_a_reply() { - // The server's excluded phrases (code 160) police what travels the search - // network: a matching file whose path carries one must not be offered, - // and a reply with nothing left is not sent at all. - let dir = std::env::temp_dir() - .join(format!("soulseek-excluded-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); - let shares = Shares::scan(&dir).unwrap(); - - assert!( - build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) - .is_some(), - "with no exclusions the file is offered" - ); - assert!( - build_search_response( - &shares, - "me", - 1, - "xyzzy", - true, - 0, - 0, - &["spam".to_string()], - ) - .is_none(), - "an excluded phrase in the path, matched case-insensitively, \ - withholds the file — and with no files there is no reply" - ); - assert!( - build_search_response( - &shares, - "me", - 1, - "xyzzy", - true, - 0, - 0, - &["unrelated".to_string()], - ) - .is_some(), - "an exclusion the path does not carry changes nothing" - ); - - // The phrases are lowercased where they arrive, so a server that sends - // one capitalised still matches a path. - let mut ctx = ClientContext::new(); - ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); - assert!( - build_search_response( - &shares, - "me", - 1, - "xyzzy", - true, - 0, - 0, - &ctx.excluded_search_phrases(), - ) - .is_none(), - "a capitalised phrase from the server still withholds the file" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn our_own_interests_are_kept_lowercased_and_deduplicated() { - // The server matches interests case-insensitively and forgets them when - // the session ends, so they are held here in one spelling to be sent - // again next login. - let mut ctx = ClientContext::new(); - assert_eq!( - ctx.add_own_interest(" Krautrock ", true).as_deref(), - Some("krautrock") - ); - assert_eq!( - ctx.add_own_interest(" ", true), - None, - "an empty interest is not stored, and is not sent either" - ); - ctx.add_own_interest("KRAUTROCK", true); - ctx.add_own_interest("Muzak", false); - - let interests = ctx.own_interests(); - assert_eq!(interests.likes, ["krautrock"]); - assert_eq!(interests.hates, ["muzak"]); - - ctx.remove_own_interest("KrautRock", true); - assert!(ctx.own_interests().likes.is_empty()); - assert_eq!( - ctx.own_interests().hates, - ["muzak"], - "the other list stands" - ); -} - -#[test] -fn leaving_a_room_drops_its_ticker_board() { - let mut ctx = ClientContext::new(); - ctx.apply_room_event(RoomEvent::Tickers { - room: "jazz".into(), - tickers: vec![RoomTicker { - username: "alice".into(), - ticker: "hi".into(), - }], - }); - ctx.apply_room_event(RoomEvent::Left { - room: "jazz".into(), - }); - assert!( - ctx.room_tickers("jazz").is_empty(), - "a board for a room we left is stale" - ); -} - -#[test] -fn the_server_is_told_our_child_capacity_only_when_it_changes() { - // AcceptChildren is a standing state on the server, not a heartbeat. - let mut ctx = ClientContext::new(); - assert_eq!( - ctx.accept_children_change(), - Some(false), - "the first answer is always worth sending" - ); - assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); -} diff --git a/soulseek-rs-lib/src/client/tests/mod.rs b/soulseek-rs-lib/src/client/tests/mod.rs new file mode 100644 index 0000000..0862c05 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/mod.rs @@ -0,0 +1,52 @@ +//! The client's context, exercised directly. +//! +//! This file is the harness the topic modules below share. + +mod peers; +mod rooms; +mod search; +mod transfers; +mod users; + +use super::*; + +fn download( + username: &str, + filename: &str, + token: u32, + status: DownloadStatus, + sender: Sender, +) -> Download { + Download { + username: username.to_string(), + filename: filename.to_string(), + token, + size: 100, + download_directory: "test".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + } +} + +fn peer_files(count: usize) -> SearchResult { + SearchResult { + token: 1, + files: (0..count) + .map(|i| crate::types::File { + username: "bob".to_string(), + name: format!("song-{i}.mp3"), + size: 1, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +use crate::types::{RoomEvent, UploadStatus}; + +use std::time::{Duration, Instant}; diff --git a/soulseek-rs-lib/src/client/tests/peers.rs b/soulseek-rs-lib/src/client/tests/peers.rs new file mode 100644 index 0000000..4f214bd --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/peers.rs @@ -0,0 +1,173 @@ +//! Uploads, broker connects, and the peers a disconnect may not drop. + +use super::*; + +#[test] +fn upload_speed_is_reported_only_while_running() { + let two_seconds_ago = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("clock supports a 2s offset"); + + // 1 KiB over ~2s is ~512 B/s. + let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); + assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); + + // A finished upload reports no rate, exactly as a finished download + // does, so the Speed column renders "-" rather than a stale figure. + for status in [ + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".to_string()), + ] { + let rate = upload_speed(&status, 1024, two_seconds_ago); + assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); + } + + // A just-started upload must not divide by a zero elapsed time. + let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); + assert!( + rate.is_finite() && rate.abs() < f64::EPSILON, + "unexpected rate {rate}" + ); +} + +// A clean disconnect — the idle reaper, or a remote client tidying an idle +// socket while it waits in our queue — must keep the peer's queued uploads; +// only an error disconnect is evidence the peer is gone. + +#[test] +fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { + let client = Client::new("test-user", "test-password"); + let (ops, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "me".to_string(), + ); + + client.context.write().unwrap().enqueue_upload( + "amy", + "@@share\\f.mp3", + std::path::PathBuf::from("/tmp/f.mp3"), + 4096, + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + None, + )) + .unwrap(); + // The loop is serial, so once this fence op is visible the disconnect + // before it has been handled. + ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while client.own_privilege_seconds() != Some(7) { + assert!(Instant::now() < deadline, "ops loop never caught up"); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"), + Some(1), + "a clean disconnect must keep the queue" + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + Some(crate::error::SoulseekRs::NotConnected), + )) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let place = client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"); + if place.is_none() { + break; + } + assert!( + Instant::now() < deadline, + "an error disconnect must drop the queue" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn a_pending_connect_expires_after_the_broker_timeout() { + let mut context = ClientContext::new(); + context.add_pending_connect(7, "ghost".to_string()); + + assert!(context.take_expired_connects(Instant::now()).is_empty()); + + let after_deadline = + Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); + assert_eq!( + context.take_expired_connects(after_deadline), + vec!["ghost".to_string()] + ); + assert!(context.take_pending_connect(7).is_none()); +} + +#[test] +fn protected_peers_covers_downloads_uploads_and_pending_serves() { + let mut context = ClientContext::new(); + context.add_download(download( + "downloader", + "song.mp3", + 1, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "done", + "old.mp3", + 2, + DownloadStatus::Completed, + mpsc::channel().0, + )); + context + .pending_serves + .insert("waiting".to_string(), vec![9]); + context.mark_browse_pending("browsed"); + + let protected = context.protected_peers(); + assert!(protected.contains("downloader")); + assert!(protected.contains("waiting")); + assert!(protected.contains("browsed")); + assert!(!protected.contains("done")); + + context.store_browse_result("browsed".to_string(), Vec::new()); + assert!(!context.protected_peers().contains("browsed")); +} + +#[test] +fn a_peer_message_nobody_could_deliver_expires() { + let mut ctx = ClientContext::for_user("me"); + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now()); + assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); + + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); + assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); +} + +#[test] +fn the_server_is_told_our_child_capacity_only_when_it_changes() { + // AcceptChildren is a standing state on the server, not a heartbeat. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.accept_children_change(), + Some(false), + "the first answer is always worth sending" + ); + assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); +} diff --git a/soulseek-rs-lib/src/client/tests/rooms.rs b/soulseek-rs-lib/src/client/tests/rooms.rs new file mode 100644 index 0000000..0af6784 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/rooms.rs @@ -0,0 +1,297 @@ +//! Room rosters, ticker boards, and the private rooms the server lets us +//! see. + +use super::*; + +#[test] +fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { + let mut context = ClientContext::new(); + + // Joining hands us the current membership in one event. + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["bob".to_string(), "alice".to_string()], + }); + assert_eq!(context.room_members("lobby"), ["alice", "bob"]); + + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "carol".to_string(), + }); + context.apply_room_event(RoomEvent::UserLeft { + room: "lobby".to_string(), + username: "bob".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice", "carol"]); + + // Events for other rooms do not leak into this one. + context.apply_room_event(RoomEvent::UserJoined { + room: "elsewhere".to_string(), + username: "dave".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice", "carol"]); + + // A room we never joined has no roster, and leaving forgets it. + assert!(context.room_members("unknown").is_empty()); + context.apply_room_event(RoomEvent::Left { + room: "lobby".to_string(), + }); + assert!(context.room_members("lobby").is_empty()); +} + +#[test] +fn a_user_joining_twice_is_listed_once() { + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["alice".to_string()], + }); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "alice".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice"]); +} + +#[test] +fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: true, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats( + "jazz".to_string(), + vec![stat("carol"), stat("alice")], + ); + + let names: Vec = context + .room_member_stats("jazz") + .into_iter() + .map(|s| s.username) + .collect(); + assert_eq!(names, vec!["alice", "carol"]); + assert!(context.room_member_stats("unjoined").is_empty()); +} + +#[test] +fn rejoining_replaces_the_previous_member_stats() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: false, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); + context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); + + let stats = context.room_member_stats("jazz"); + assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); + assert_eq!(stats[0].username, "bob"); +} + +#[test] +fn a_new_ticker_replaces_that_users_previous_one() { + // The server treats a user's ticker as singular: a second one from the + // same user supersedes the first rather than stacking beside it. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "first".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "bobs".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerAdded { + room: "jazz".into(), + username: "alice".into(), + ticker: "second".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 2); + assert_eq!( + board.iter().find(|t| t.username == "alice").unwrap().ticker, + "second" + ); +} + +#[test] +fn a_removed_ticker_leaves_the_rest_of_the_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "a".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "b".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerRemoved { + room: "jazz".into(), + username: "alice".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 1); + assert_eq!(board[0].username, "bob"); +} + +#[test] +fn a_global_message_is_queued_without_touching_room_membership() { + // The global feed carries messages from rooms we have not joined; they + // must not invent a roster for those rooms. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::GlobalMessage { + room: "elsewhere".into(), + username: "alice".into(), + message: "hi".into(), + }); + assert!(ctx.room_members("elsewhere").is_empty()); + assert_eq!(ctx.take_room_events().len(), 1); +} + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_roster_we_can_still_see() { + // Demotion does not blind us: we are still a member, and the room's other + // operators are still ours to show. The server narrates our own removal + // from that roster separately (code 144). + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into(), "bob".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert_eq!( + ctx.private_room_operators("club"), + vec!["alice".to_string(), "bob".to_string()], + "the other operators are still there to show" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: false, + added: false, + }); + assert_eq!( + ctx.private_room_operators("club"), + vec!["bob".to_string()], + "only the demotion the server narrates removes us" + ); +} + +#[test] +fn leaving_a_room_drops_its_ticker_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![RoomTicker { + username: "alice".into(), + ticker: "hi".into(), + }], + }); + ctx.apply_room_event(RoomEvent::Left { + room: "jazz".into(), + }); + assert!( + ctx.room_tickers("jazz").is_empty(), + "a board for a room we left is stale" + ); +} diff --git a/soulseek-rs-lib/src/client/tests/search.rs b/soulseek-rs-lib/src/client/tests/search.rs new file mode 100644 index 0000000..f18fe93 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/search.rs @@ -0,0 +1,167 @@ +//! Answering a search against our shares, and what the client keeps of +//! the answers it gets back. + +use super::*; + +#[test] +fn build_search_response_matches_shares_and_echoes_token() { + let dir = std::env::temp_dir() + .join(format!("soulseek-searchresp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + let response = + build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) + .expect("a matching share yields a response"); + let mut decoded = + crate::message::Message::new_with_data(response.get_buffer()); + decoded.set_pointer(8); + let result = SearchResult::new_from_message(&mut decoded).unwrap(); + assert_eq!(result.username, "me"); + assert_eq!(result.token, 99); + assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); + + assert!( + build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) + .is_none() + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn search_file_counts_cover_every_search_without_the_results() { + // The daemon polls these counts many times a second; they must come from + // a walk of the cache, never a copy of it. + let client = Client::new("u", "p"); + { + let mut context = client.context.write().unwrap(); + context.searches.insert( + "aphex twin".to_string(), + Search { + token: 1, + results: vec![peer_files(2), peer_files(1)], + }, + ); + context.searches.insert( + "nothing yet".to_string(), + Search { + token: 2, + results: Vec::new(), + }, + ); + } + + let mut counts = client.search_file_counts(); + counts.sort(); + assert_eq!( + counts, + [ + ("aphex twin".to_string(), 3), + ("nothing yet".to_string(), 0) + ] + ); +} + +#[test] +fn a_search_stops_collecting_once_it_has_enough_responses() { + // A popular query on the live network draws answers for minutes — + // nearly a million files and gigabytes of memory for one search. + // Surplus responders are dropped, not archived. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { + search.accept(peer_files(1)); + } + assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); +} + +#[test] +fn a_flood_of_files_fills_a_search_before_the_response_cap() { + // A handful of whales with huge matching collections must not add up + // to an unbounded set just because the responses are few. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..10 { + search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); + } + assert_eq!( + search.results.len(), + 2, + "two half-cap responses fill the search; the rest are dropped" + ); +} + +#[test] +fn a_file_the_server_excludes_is_left_out_of_a_reply() { + // The server's excluded phrases (code 160) police what travels the search + // network: a matching file whose path carries one must not be offered, + // and a reply with nothing left is not sent at all. + let dir = std::env::temp_dir() + .join(format!("soulseek-excluded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + assert!( + build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) + .is_some(), + "with no exclusions the file is offered" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["spam".to_string()], + ) + .is_none(), + "an excluded phrase in the path, matched case-insensitively, \ + withholds the file — and with no files there is no reply" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["unrelated".to_string()], + ) + .is_some(), + "an exclusion the path does not carry changes nothing" + ); + + // The phrases are lowercased where they arrive, so a server that sends + // one capitalised still matches a path. + let mut ctx = ClientContext::new(); + ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &ctx.excluded_search_phrases(), + ) + .is_none(), + "a capitalised phrase from the server still withholds the file" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/soulseek-rs-lib/src/client/tests/transfers.rs b/soulseek-rs-lib/src/client/tests/transfers.rs new file mode 100644 index 0000000..dbabdd3 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/transfers.rs @@ -0,0 +1,329 @@ +//! Downloads through the context: what is queued, paused, cancelled, +//! and what a peer's answer is allowed to start. + +use super::*; + +#[test] +fn test_client_context_downloads() { + let mut context = ClientContext::new(); + let token = 123; + let new_token = 1234; + context.add_download(download( + "test", + "test.txt", + token, + DownloadStatus::Queued, + mpsc::channel().0, + )); + assert!(context.get_download_by_token(123).is_some()); + assert_eq!(context.get_download_tokens(), vec![123]); + assert_eq!(context.get_downloads().len(), 1); + if let Some(download) = context.get_download_by_token_mut(token) { + assert_eq!(download.token, token); + download.token = new_token; + } + assert!(context.get_download_by_token(new_token).is_some()); + assert_eq!(context.get_download_tokens(), vec![new_token]); + context.remove_download(new_token); + assert_eq!(context.get_downloads().len(), 0); + assert!(context.get_download_by_token(1234).is_none()); +} + +#[test] +fn test_client_pause_and_resume_download() { + let client = Client::new("test-user", "test-password"); + let (download_sender, download_receiver) = mpsc::channel(); + + client.context.write().unwrap().add_download(download( + "peer", + "song.mp3", + 123, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + download_sender, + )); + + assert!(client.pause_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + download_receiver.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(client.resume_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); +} + +#[test] +fn download_without_a_connection_resolves_failed() { + // A client that never connected has no server handle and no peer registry, + // so it cannot open a connection to the peer: the download must resolve to + // Failed rather than hang Queued forever. + let client = Client::new("test-user", "test-password"); + let (_download, receiver) = client + .download( + "song.mp3".to_string(), + "peer".to_string(), + 100, + "test".to_string(), + ) + .expect("download() should return a handle"); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)), + Ok(DownloadStatus::Failed(_)) + )); +} + +#[test] +fn fail_queued_downloads_notifies_receiver_and_store() { + // When a brokered connect times out, every Queued download for the peer + // must resolve to Failed both on its channel and in the store. + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + + Client::fail_queued_downloads(&client.context, "peer"); + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(7) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + { + let mut context = client.context.write().unwrap(); + context.add_download(download( + "peer", + "queued.mp3", + 123, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "peer", + "active.mp3", + 456, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + mpsc::channel().0, + )); + } + + assert!(client.remove_queued_download("peer", "queued.mp3")); + assert!(!client.remove_queued_download("peer", "active.mp3")); + let context = client.context.read().unwrap(); + assert!(context.get_download_by_token(123).is_none()); + assert!(context.get_download_by_token(456).is_some()); +} + +#[test] +fn an_expired_broker_connect_fails_the_queued_downloads() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + { + let mut ctx = client.context.write().unwrap(); + ctx.add_download(download( + "ghost", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + ctx.pending_connect_tokens + .insert(7, ("ghost".to_string(), Instant::now())); + } + let (_ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let status = receiver.recv_timeout(Duration::from_secs(5)); + assert!( + matches!(status, Ok(DownloadStatus::Failed(_))), + "the sweep must fail the queued download, got {status:?}" + ); +} + +#[test] +fn a_replayed_transfer_response_does_not_start_a_second_transfer() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + let download_dir = std::env::temp_dir() + .join(format!("soulseek-replayed-transfer-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&download_dir); + std::fs::create_dir_all(&download_dir).unwrap(); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + let mut queued = + download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); + queued.download_directory = download_dir.display().to_string(); + client.context.write().unwrap().add_download(queued); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) + .unwrap(); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut connections = 0; + while Instant::now() < deadline { + match listener.accept() { + Ok(_) => { + connections += 1; + if connections == 1 { + thread::sleep(Duration::from_millis(500)); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if connections >= 1 { + break; + } + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + assert_eq!( + connections, 1, + "a replayed TransferResponse must not dial the peer again" + ); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Cancelled, + sender, + )); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + thread::sleep(Duration::from_millis(500)); + assert!( + matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), + "a cancelled download must not dial the peer" + ); +} + +#[test] +fn cancel_download_marks_the_store_and_answers_whether_it_matched() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Queued, + sender, + )); + + assert!(client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "other.mp3")); + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); + assert!(matches!( + client.get_all_downloads()[0].status, + DownloadStatus::Cancelled + )); +} diff --git a/soulseek-rs-lib/src/client/tests/users.rs b/soulseek-rs-lib/src/client/tests/users.rs new file mode 100644 index 0000000..4094b04 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/users.rs @@ -0,0 +1,200 @@ +//! What the server tells us about people: watches, interests, and the +//! two halves of a user snapshot. + +use super::*; + +#[test] +fn a_fresh_request_discards_the_previous_answer() { + // Without this, a poll after a second request returns the old + // snapshot immediately and the caller cannot tell stale from fresh. + let mut context = ClientContext::new(); + context.apply_user_status("alice".to_string(), 2, false); + context.apply_user_stats("alice".to_string(), 10, 20, 30); + assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); + + context.invalidate_user_info("alice"); + assert!( + context.user_info("alice").is_none(), + "a new request must not be answerable from the old reply" + ); +} + +#[test] +fn each_reply_fills_only_its_own_half() { + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + + let info = context.user_info("bob").expect("a snapshot"); + assert!(!info.is_complete(), "stats have not arrived"); + assert_eq!( + info.presence.map(|p| p.status), + Some(crate::types::UserStatus::Away) + ); + assert!(info.stats.is_none(), "must not invent statistics"); + + context.apply_user_stats("bob".to_string(), 5, 6, 7); + let info = context.user_info("bob").expect("a snapshot"); + assert!(info.is_complete()); + assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); + assert_eq!( + info.presence.map(|p| p.privileged), + Some(true), + "the earlier half must survive the merge" + ); +} + +#[test] +fn a_watch_reply_fills_both_halves_of_the_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_watched_user( + "alice".to_string(), + true, + Some(2), + Some(1024), + Some(20), + Some(3), + ); + + let info = context.user_info("alice").expect("a snapshot"); + assert!(info.is_complete(), "a watch reply carries status and stats"); + assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); + assert_eq!(context.watched_users(), vec!["alice".to_string()]); +} + +#[test] +fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { + // WatchUser has no privileged field, so it must not overwrite what + // GetUserStatus already told us with a fabricated `false`. + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + context.apply_watched_user( + "bob".to_string(), + true, + Some(2), + Some(1), + Some(2), + Some(3), + ); + + let info = context.user_info("bob").expect("a snapshot"); + assert_eq!(info.presence.map(|p| p.privileged), Some(true)); +} + +#[test] +fn watching_an_unknown_user_drops_them_from_the_list() { + // The server will never push status for a name it does not know, so + // keeping it in the watch list would show a permanently blank row. + let mut context = ClientContext::new(); + context.add_watched_user("ghost"); + context.apply_watched_user( + "ghost".to_string(), + false, + None, + None, + None, + None, + ); + + assert!(context.watched_users().is_empty()); + assert!(context.user_info("ghost").is_none()); +} + +#[test] +fn unwatching_forgets_the_users_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_user_status("alice".to_string(), 2, false); + + context.remove_watched_user("alice"); + assert!(context.watched_users().is_empty()); + assert!( + context.user_info("alice").is_none(), + "a later re-watch must report a fresh answer" + ); +} + +#[test] +fn watched_users_are_listed_in_a_stable_order() { + let mut context = ClientContext::new(); + context.add_watched_user("carol"); + context.add_watched_user("alice"); + context.add_watched_user("bob"); + assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); +} + +// A reply queued for a searcher the server cannot place waits a minute, +// not forever: every such search would otherwise pin a map entry and a +// protected slot in the peer registry. + +#[test] +fn own_and_global_recommendations_are_kept_apart() { + let mut ctx = ClientContext::new(); + ctx.apply_recommendations( + false, + vec![Recommendation { + item: "jazz".into(), + rating: 2, + }], + Vec::new(), + ); + ctx.apply_recommendations( + true, + vec![Recommendation { + item: "pop".into(), + rating: 9, + }], + Vec::new(), + ); + + assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); + assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); +} + +#[test] +fn asking_again_about_a_users_interests_drops_the_previous_answer() { + let mut ctx = ClientContext::new(); + ctx.apply_user_interests(UserInterests { + username: "alice".into(), + likes: vec!["jazz".into()], + hates: Vec::new(), + }); + assert!(ctx.user_interests("alice").is_some()); + + ctx.invalidate_user_interests("alice"); + assert!( + ctx.user_interests("alice").is_none(), + "a stale answer must not be mistaken for the next one" + ); +} + +#[test] +fn our_own_interests_are_kept_lowercased_and_deduplicated() { + // The server matches interests case-insensitively and forgets them when + // the session ends, so they are held here in one spelling to be sent + // again next login. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.add_own_interest(" Krautrock ", true).as_deref(), + Some("krautrock") + ); + assert_eq!( + ctx.add_own_interest(" ", true), + None, + "an empty interest is not stored, and is not sent either" + ); + ctx.add_own_interest("KRAUTROCK", true); + ctx.add_own_interest("Muzak", false); + + let interests = ctx.own_interests(); + assert_eq!(interests.likes, ["krautrock"]); + assert_eq!(interests.hates, ["muzak"]); + + ctx.remove_own_interest("KrautRock", true); + assert!(ctx.own_interests().likes.is_empty()); + assert_eq!( + ctx.own_interests().hates, + ["muzak"], + "the other list stands" + ); +} diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 67178af..3658f4c 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -264,2321 +264,4 @@ pub fn launch_main_tui( } #[cfg(test)] -mod tests { - 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, - downloads: Vec, - searches: std::sync::Mutex>, - /// What `try_get_search_results` hands back to the window. - results: std::sync::Mutex>, - /// Every query a window put on the wire, in order. - searched: std::sync::Mutex>, - /// 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>, - /// What `set_shared_directories` was last asked for, if anything. - shares_set: std::sync::Mutex>>, - /// What `change_password` was last asked for, if anything. - password_set: std::sync::Mutex>, - /// The share index this session serves to peers. - listing: std::sync::Mutex>, - download_cancelled: std::sync::Mutex>, - upload_cancelled: std::sync::Mutex>, - download_removed: std::sync::Mutex>, - } - - 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 { - self.history.clone() - } - fn daemon_endpoint(&self) -> Option { - self.shared.then(|| "test-daemon".to_string()) - } - fn username(&self) -> String { - "tester".to_string() - } - fn listen_port(&self) -> Option { - None - } - fn session_loss(&self) -> Option { - None - } - fn search_with_cancel( - &self, - query: &str, - _timeout: Duration, - _cancel: Option>, - ) -> soulseek_rs::Result> { - self.searched - .lock() - .expect("not poisoned") - .push(query.to_string()); - Ok(Vec::new()) - } - fn get_search_results( - &self, - _key: &str, - ) -> Vec { - Vec::new() - } - fn all_searches(&self) -> Vec { - 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> { - 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, - )> { - 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, - )> { - Err(soulseek_rs::SoulseekRs::NotConnected) - } - fn get_all_downloads(&self) -> Vec { - 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 { - Vec::new() - } - fn take_upload_events(&self) -> Vec { - 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 { - 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 { - Vec::new() - } - fn room_member_stats( - &self, - _room: &str, - ) -> Vec { - Vec::new() - } - fn take_room_events(&self) -> Vec { - Vec::new() - } - fn send_private_message( - &self, - _u: &str, - _m: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn take_private_messages(&self) -> Vec { - Vec::new() - } - fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn take_browse_result( - &self, - _username: &str, - ) -> Option> { - 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 { - Vec::new() - } - fn user_info(&self, _username: &str) -> Option { - 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 { - self.listing.lock().expect("not poisoned").clone() - } - fn shared_directories(&self) -> Vec { - Vec::new() - } - fn set_shared_directories( - &self, - directories: Vec, - ) -> 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) -> MainTui { - with_session(TalkativeSession { - history, - shared: true, - ..TalkativeSession::default() - }) - } - - fn shared(searches: Vec) -> 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), - } - } - - #[test] - fn x_cancels_the_highlighted_download() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "song.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 4096, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - vec![("bob".to_string(), "song.mp3".to_string())] - ); - assert!( - session - .upload_cancelled - .lock() - .expect("not poisoned") - .is_empty(), - "the download row cancels no upload" - ); - } - - #[test] - fn shift_c_clears_the_whole_transfer_list() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "waiting.mp3"), - receiver: None, - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "carol", - "live.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 4096, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("dave", "done.mp3"), - receiver: None, - }); - tui.state.uploads.push(uploading("alice", "served.mp3")); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('C')); - - assert!(tui.state.downloads.is_empty(), "every row goes"); - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - vec![ - ("bob".to_string(), "waiting.mp3".to_string()), - ("carol".to_string(), "live.mp3".to_string()), - ], - "live downloads are cancelled; the finished one is not" - ); - assert_eq!( - *session.download_removed.lock().expect("not poisoned"), - vec![ - ("bob".to_string(), "waiting.mp3".to_string()), - ("carol".to_string(), "live.mp3".to_string()), - ("dave".to_string(), "done.mp3".to_string()), - ], - "the session forgets every download, not just this window's rows" - ); - assert_eq!( - *session.upload_cancelled.lock().expect("not poisoned"), - vec![("alice".to_string(), "served.mp3".to_string())], - "a streaming upload is cancelled too" - ); - } - - #[test] - fn d_clears_a_cancelled_row() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "gone.mp3", - soulseek_rs::DownloadStatus::Cancelled, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('d')); - - assert!(tui.state.downloads.is_empty(), "the row goes"); - } - - #[test] - fn x_on_a_finished_download_asks_nothing() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("bob", "done.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert!( - session - .download_cancelled - .lock() - .expect("not poisoned") - .is_empty(), - "a finished download is not cancelled" - ); - } - - #[test] - fn x_on_an_upload_row_still_cancels_the_upload() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "song.mp3"), - receiver: None, - }); - tui.state.uploads.push(uploading("alice", "served.mp3")); - tui.state.downloads_table_state.select(Some(1)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.upload_cancelled.lock().expect("not poisoned"), - vec![("alice".to_string(), "served.mp3".to_string())] - ); - assert!( - session - .download_cancelled - .lock() - .expect("not poisoned") - .is_empty(), - "the upload row cancels no download" - ); - } - - #[test] - fn b_browses_the_user_of_the_highlighted_download() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "song.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('b')); - - assert!(tui.state.show_browse, "the browse popup opens"); - assert_eq!( - tui.state.browse.active_tab().map(|b| b.username.as_str()), - Some("bob"), - "the download's user is the one browsed" - ); - assert!( - !tui.state.command_bar_active, - "b on a transfer goes straight to the user, not the prompt" - ); - } - - #[test] - fn b_on_an_upload_row_browses_that_user() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.uploads.push(uploading("alice", "served.mp3")); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('b')); - - assert_eq!( - tui.state.browse.active_tab().map(|b| b.username.as_str()), - Some("alice") - ); - } - - fn with_session(session: TalkativeSession) -> MainTui { - attach(Arc::new(session)) - } - - fn attach(session: Arc) -> MainTui { - MainTui::new( - session, - "/tmp".to_string(), - Duration::from_secs(1), - None, - None, - ) - } - - #[test] - fn an_attached_session_opens_with_the_conversation_already_in_it() { - // A TUI attached to a daemon has no state file of its own; the - // conversation has to come from the session, or it starts blank while - // the daemon has been collecting messages for hours. - let tui = tui(vec![ - ChatMessageDto { - peer: "bob".into(), - outgoing: false, - text: "hi".into(), - at: 1, - }, - ChatMessageDto { - peer: "bob".into(), - outgoing: true, - text: "hello".into(), - at: 2, - }, - ]); - - assert_eq!(tui.state.messages.len(), 2); - assert_eq!(tui.state.messages[0].peer, "bob"); - assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); - assert_eq!( - tui.state.messages[1].direction, - MessageDirection::Outgoing, - "both halves of the conversation, not just what arrived" - ); - assert_eq!(tui.state.chat_peers(), ["bob"]); - } - - #[test] - fn a_window_shows_transfers_it_did_not_start() { - // Two windows on one daemon are two views of one queue. A transfer - // queued in the other one has to appear here, or closing a window - // would look like losing its downloads. - let mut tui = with_session(TalkativeSession { - downloads: vec![queued("bob", "@@x\\theirs.mp3")], - shared: true, - ..TalkativeSession::default() - }); - assert!( - tui.state.downloads.is_empty(), - "nothing until the first poll" - ); - - tui.update_downloads(); - - assert_eq!(tui.state.downloads.len(), 1); - assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); - assert!( - tui.state.downloads[0].receiver.is_none(), - "someone else's transfer has no channel of ours" - ); - - // Polling again must not double it up. - tui.update_downloads(); - assert_eq!(tui.state.downloads.len(), 1); - } - - #[test] - fn a_transfer_the_session_forgets_leaves_every_window() { - let mut tui = with_session(TalkativeSession { - downloads: vec![queued("bob", "gone.mp3")], - shared: true, - ..TalkativeSession::default() - }); - tui.update_downloads(); - assert_eq!(tui.state.downloads.len(), 1); - - // The session no longer has it — cancelled from the other window. - tui.client = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - tui.update_downloads(); - assert!( - tui.state.downloads.is_empty(), - "a queue is shared in both directions" - ); - } - - #[test] - fn d_clears_a_finished_row_but_not_a_running_one() { - // Finished transfers are what a list fills up with after a restart, so - // `d` has to be able to dismiss one — restored or not. A running - // transfer is not ours to discard, and stays put. - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("bob", "done.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('d')); - - assert!(tui.state.downloads.is_empty(), "the row goes too"); - assert_eq!(tui.state.downloads_table_state.selected(), None); - - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "still.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 2, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - - tui.handle_key_event(key('d')); - - assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); - } - - #[test] - fn a_window_shows_searches_run_in_another() { - let mut tui = - with_session(shared(vec![search_of("their query", 7, 9_999)])); - tui.update_search_results(); - - assert_eq!(tui.state.searches.len(), 1); - assert_eq!(tui.state.searches[0].query, "their query"); - - // And it is not added a second time on the next frame. - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - } - - #[test] - fn a_search_another_window_is_still_running_shows_as_active() { - // It had said "Done" the moment it appeared, because an imported - // search carried no notion of still collecting — so the other window - // looked finished while results were still arriving. - let mut tui = - with_session(shared(vec![search_of("still going", 3, 0)])); - tui.update_search_results(); - - assert_eq!(tui.state.searches[0].status, SearchStatus::Active); - assert_eq!( - tui.state.searches[0].known_files, 3, - "and its count comes across before the results do" - ); - } - - #[test] - fn a_search_that_has_run_its_window_shows_as_done() { - let mut tui = - with_session(shared(vec![search_of("finished", 12, 9_999)])); - tui.update_search_results(); - assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); - } - - #[test] - fn removing_a_search_removes_it_from_the_session() { - // Removing it only from this window would last until the next sync, - // which would put it straight back — and in a shared session it would - // come back for every other window too. - let mut tui = - with_session(shared(vec![search_of("unwanted", 1, 9_999)])); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - - tui.remove_search_at_index(0); - tui.update_search_results(); - - assert!(tui.state.searches.is_empty(), "and it stays gone"); - } - - #[test] - fn clearing_every_search_clears_them_in_the_session_too() { - let mut tui = with_session(shared(vec![ - search_of("one", 1, 9_999), - search_of("two", 2, 9_999), - ])); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 2); - - tui.clear_all_searches(); - tui.update_search_results(); - - assert!( - tui.state.searches.is_empty(), - "clearing has to reach the session, or every row comes back" - ); - } - - #[test] - fn a_search_dismissed_in_another_window_leaves_this_one() { - let session = shared(vec![search_of("theirs", 4, 9_999)]); - let mut tui = with_session(session); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - - // The other window dismissed it. - tui.client.forget_search("theirs"); - tui.update_search_results(); - - assert!(tui.state.searches.is_empty()); - } - - 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(), - } - } - - #[test] - fn a_tick_asks_the_session_for_its_searches_once() { - // Against a daemon every call is a round trip, and the daemon answers - // it by walking its whole search cache — twice per frame was half the - // cost of attaching at all. - let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); - let mut tui = attach(session.clone()); - - tui.update_search_results(); - - assert_eq!( - session - .all_searches_calls - .load(std::sync::atomic::Ordering::Relaxed), - 1, - "one sync means one listing, not one per use of it" - ); - } - - /// 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, 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) - } - - #[test] - fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { - // The result set only accumulates, so an unchanged count means a - // fetch would hand back what this window already holds — against a - // daemon that is the entire result set over the socket, every frame, - // forever. - let (session, tui) = window_that_pulled_two_files(); - - assert_eq!(tui.state.searches[0].results.len(), 2); - assert_eq!( - session - .result_fetches - .load(std::sync::atomic::Ordering::Relaxed), - 1, - "the set crosses once; after that the unchanged count spares it" - ); - } - - #[test] - fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { - let (session, mut tui) = window_that_pulled_two_files(); - - // A late responder lands in the session. - *session.searches.lock().expect("not poisoned") = - vec![search_of("aphex twin", 3, 9_999)]; - *session.results.lock().expect("not poisoned") = - vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; - tui.update_search_results(); - - assert_eq!( - tui.state.searches[0].results.len(), - 3, - "a changed count must bring the new results across" - ); - } - - #[test] - fn a_local_window_fetches_results_without_counting_on_the_session() { - // Locally nothing maintains known_files, so the count gate must stay - // out of the way: a search whose count still reads zero has to get - // its results anyway. - let session = Arc::new(TalkativeSession { - results: std::sync::Mutex::new(vec![files_from_bob(&[ - "@@x\\a.mp3", - "@@x\\b.mp3", - ])]), - shared: false, - ..TalkativeSession::default() - }); - let mut tui = attach(session); - tui.state.searches.push(crate::models::SearchEntry { - query: "mine".to_string(), - status: SearchStatus::Active, - results: Vec::new(), - known_files: 0, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - - tui.update_search_results(); - - assert_eq!( - tui.state.searches[0].results.len(), - 2, - "a zero count matching zero held rows must not read as \ - nothing-to-fetch on a local session" - ); - } - - #[test] - fn a_window_with_its_own_session_is_left_alone() { - // Nothing to share, so nothing is synced — and in particular the - // completed transfers restored from disk, which the session has never - // heard of, are not mistaken for cancelled and deleted. - let mut tui = with_session(TalkativeSession { - downloads: Vec::new(), - shared: false, - ..TalkativeSession::default() - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "from-disk.mp3"), - receiver: None, - }); - - tui.update_downloads(); - - assert_eq!( - tui.state.downloads.len(), - 1, - "a local window's history must survive the tick" - ); - } - - #[test] - fn a_local_session_starts_from_its_own_snapshot_instead() { - // Locally the session knows no history and the state file is the whole - // record, so nothing is invented. - assert!(tui(Vec::new()).state.messages.is_empty()); - } - - /// Attached, both folders in the settings form name paths on the - /// *daemon's* machine: they go over the socket exactly as typed, and - /// nothing is created or validated against this machine's filesystem. - #[test] - fn applying_settings_attached_pushes_both_folders_to_the_daemon() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let scratch = tempfile::tempdir().expect("a temp dir"); - let config_path = scratch.path().join("config.toml"); - let mut tui = MainTui::new( - session.clone(), - "/daemon/old".to_string(), - Duration::from_secs(1), - None, - Some(config_path.clone()), - ); - - let daemon_only = scratch.path().join("only-on-the-daemon"); - let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); - tui.open_settings(); - { - let settings = tui.state.settings.as_mut().expect("open"); - settings.download_dir.clone_from(&daemon_dir); - settings.share_dirs = vec!["/not-here-either".to_string()]; - } - tui.apply_settings(); - - assert_eq!( - *session.download_dir_set.lock().expect("not poisoned"), - Some(daemon_dir.clone()), - "the folder is pushed to the daemon" - ); - assert_eq!( - *session.shares_set.lock().expect("not poisoned"), - Some(vec!["/not-here-either".to_string()]), - "shares go over as typed — the daemon's filesystem judges them" - ); - assert!( - !daemon_only.exists(), - "no directory is created on this machine for a daemon's path" - ); - assert_eq!(tui.download_dir, daemon_dir); - - let saved = crate::persist::config::FileConfig::load(&config_path) - .expect("config readable"); - assert_eq!( - saved.download_dir.as_deref(), - Some(daemon_dir.as_str()), - "the change still lands in config.toml for the next restart" - ); - } - - /// Locally the same form works on this machine: the folder is created - /// here and share paths that do not exist here are dropped before the - /// session hears about them. - #[test] - fn applying_settings_locally_stays_on_this_machine() { - let session = Arc::new(TalkativeSession { - shared: false, - ..TalkativeSession::default() - }); - let scratch = tempfile::tempdir().expect("a temp dir"); - let config_path = scratch.path().join("config.toml"); - let share = scratch.path().join("music"); - std::fs::create_dir_all(&share).expect("share dir"); - let mut tui = MainTui::new( - session.clone(), - "/tmp".to_string(), - Duration::from_secs(1), - None, - Some(config_path), - ); - - let downloads = scratch.path().join("downloads"); - let downloads_dir = downloads.to_str().expect("utf-8").to_string(); - tui.open_settings(); - { - let settings = tui.state.settings.as_mut().expect("open"); - settings.download_dir.clone_from(&downloads_dir); - settings.share_dirs = vec![ - share.to_str().expect("utf-8").to_string(), - "/definitely-not-a-real-share".to_string(), - ]; - } - tui.apply_settings(); - - // The seam is told in both modes; a local session accepts and takes - // the directory per download instead of storing it. - assert_eq!( - *session.download_dir_set.lock().expect("not poisoned"), - Some(downloads_dir.clone()) - ); - assert_eq!( - *session.shares_set.lock().expect("not poisoned"), - Some(vec![share.to_str().expect("utf-8").to_string()]), - "paths missing on this machine are dropped before applying" - ); - assert!(downloads.is_dir(), "the folder is created here"); - assert_eq!(tui.download_dir, downloads_dir); - } - - /// The account block answers "who is this session, and what is it - /// offering" without a trip to another screen. - #[test] - fn the_settings_popup_names_the_account_and_its_shares() { - let mut tui = with_session(TalkativeSession::default()); - press(&mut tui, KeyCode::Char('o')); - - let screen = screen_sized(&mut tui, 100, 30); - assert!(screen.contains("tester"), "{screen}"); - assert!(screen.contains("12 files in 3 folders"), "{screen}"); - } - - /// Typed twice the same way, the new password reaches the session — and - /// attached to a daemon it is the daemon that stores it. - #[test] - fn a_password_typed_twice_reaches_the_session() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let mut tui = attach(session.clone()); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Enter); // the change-password row is first - for _ in 0..2 { - for c in "hunter2".chars() { - press(&mut tui, KeyCode::Char(c)); - } - press(&mut tui, KeyCode::Enter); - } - - assert_eq!( - *session.password_set.lock().expect("not poisoned"), - Some("hunter2".to_string()) - ); - let settings = tui.state.settings.as_ref().expect("still open"); - assert_eq!(settings.status.as_deref(), Some("Password changed")); - } - - /// A mistyped repeat is caught here rather than on the server, where the - /// account would be left with a password nobody knows. - #[test] - fn a_password_typed_two_different_ways_is_not_sent() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let mut tui = attach(session.clone()); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Enter); - for typed in ["hunter2", "hunter3"] { - for c in typed.chars() { - press(&mut tui, KeyCode::Char(c)); - } - press(&mut tui, KeyCode::Enter); - } - - assert_eq!(*session.password_set.lock().expect("not poisoned"), None); - } - - /// Logging out ends the window, but as a log-out: the caller brings the - /// login screen back rather than returning to the shell. - #[test] - fn logging_out_asks_first_and_then_closes_the_window() { - let mut tui = with_session(TalkativeSession::default()); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Down); // change password -> log out - press(&mut tui, KeyCode::Enter); - assert!(tui.state.exit.is_none(), "the confirm comes first"); - - press(&mut tui, KeyCode::Char('y')); - assert_eq!(tui.state.exit, Some(TuiExit::Logout)); - } - - /// Attached, the login is the daemon's: there is no logout row to land - /// on, so the row below the password is the download folder. - #[test] - fn an_attached_window_offers_no_logout() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let mut tui = attach(session); - press(&mut tui, KeyCode::Char('o')); - press(&mut tui, KeyCode::Down); - press(&mut tui, KeyCode::Enter); - - let settings = tui.state.settings.as_ref().expect("still open"); - assert_eq!( - settings.mode, - crate::models::SettingsMode::EditingDownloadDir - ); - assert!(tui.state.exit.is_none()); - } - - fn results(n: usize) -> Vec { - (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 { - tui.state.results_table_state.selected() - } - - #[test] - fn end_and_home_jump_to_the_last_and_first_result() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::End); - assert_eq!(selected(&tui), Some(29)); - press(&mut tui, KeyCode::Home); - assert_eq!(selected(&tui), Some(0)); - press(&mut tui, KeyCode::Char('G')); - assert_eq!(selected(&tui), Some(29)); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(selected(&tui), Some(0)); - } - - #[test] - fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::PageDown); - assert_eq!(selected(&tui), Some(10)); - ctrl(&mut tui, 'f'); - assert_eq!(selected(&tui), Some(20)); - press(&mut tui, KeyCode::PageDown); - assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); - press(&mut tui, KeyCode::PageUp); - assert_eq!(selected(&tui), Some(19)); - ctrl(&mut tui, 'b'); - assert_eq!(selected(&tui), Some(9)); - press(&mut tui, KeyCode::PageUp); - assert_eq!(selected(&tui), Some(0)); - assert!( - !tui.state.show_browse && !tui.state.command_bar_active, - "ctrl-b pages up instead of opening browse" - ); - } - - #[test] - fn ctrl_d_and_ctrl_u_move_half_a_pane() { - let mut tui = results_tui(30); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), Some(5)); - ctrl(&mut tui, 'u'); - assert_eq!(selected(&tui), Some(0)); - } - - #[test] - fn navigation_keys_do_nothing_without_results() { - let mut tui = results_tui(0); - tui.state.results_table_state.select(None); - press(&mut tui, KeyCode::End); - press(&mut tui, KeyCode::PageDown); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), None); - } - - #[test] - fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.results_name_offset, 8); - press(&mut tui, KeyCode::Right); - assert_eq!(tui.state.results_name_offset, 16); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); - press(&mut tui, KeyCode::Char('h')); - assert_eq!(tui.state.results_name_offset, 22); - press(&mut tui, KeyCode::Left); - assert_eq!(tui.state.results_name_offset, 14); - press(&mut tui, KeyCode::Char('0')); - assert_eq!(tui.state.results_name_offset, 0); - press(&mut tui, KeyCode::Char('h')); - assert_eq!(tui.state.results_name_offset, 0); - } - - #[test] - fn scrolling_follows_the_highlighted_row_not_the_longest_one() { - let mut tui = results_tui(3); - tui.state.results_items[0].filename = "short.mp3".to_string(); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_items[2].filename = "short.mp3".to_string(); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - tui.state.results_table_state.select(Some(2)); - press(&mut tui, KeyCode::Char('h')); - assert_eq!( - tui.state.results_name_offset, 0, - "a step left lands within the row now highlighted" - ); - } - - #[test] - fn picking_another_search_starts_its_results_unscrolled() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - - tui.state.searches.push(crate::models::SearchEntry { - query: "other".to_string(), - status: SearchStatus::Active, - results: results(2), - known_files: 2, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.searches_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Enter); - assert_eq!(tui.state.results_name_offset, 0); - } - - #[test] - fn ctrl_keys_are_ignored_outside_the_results_pane() { - let mut tui = results_tui(3); - tui.state.searches.push(crate::models::SearchEntry { - query: "keep me".to_string(), - status: SearchStatus::Active, - results: Vec::new(), - known_files: 0, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.searches_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Searches; - ctrl(&mut tui, 'd'); - assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); - ctrl(&mut tui, 'b'); - assert!(!tui.state.command_bar_active, "ctrl-b is not b"); - ctrl(&mut tui, 'q'); - assert!(tui.state.exit.is_none(), "ctrl-q is not q"); - } - - #[test] - fn ctrl_keys_page_while_a_filter_is_being_typed() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::Char('/')); - press(&mut tui, KeyCode::Char('m')); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), Some(5)); - assert_eq!(tui.state.results_filter_query, "m", "no d appended"); - press(&mut tui, KeyCode::End); - assert_eq!(selected(&tui), Some(29)); - } - - #[test] - fn space_after_a_filter_that_matches_nothing_does_not_panic() { - let mut tui = results_tui(3); - press(&mut tui, KeyCode::Char('/')); - press(&mut tui, KeyCode::Char('z')); - press(&mut tui, KeyCode::Enter); - assert!(tui.state.results_filtered_items.is_empty()); - press(&mut tui, KeyCode::Char(' ')); - assert!(tui.state.results_selected_indices.is_empty()); - } - - 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 - } - - #[test] - fn the_results_pane_has_the_whole_width_to_itself() { - let mut tui = furnished_tui(); - let screen = screen_of(&mut tui); - - let results = row_with(&screen, "[2] Results"); - assert!(results.starts_with('╭'), "{results}"); - assert!(results.trim_end().ends_with('╮'), "{results}"); - assert!(!results.contains("[1]"), "nothing beside it: {results}"); - - // The other three share the row underneath, in this order. - let row = row_with(&screen, "[1] Searches"); - let searches = row.find("[1]").expect("searches"); - let downloads = row.find("[3]").expect("downloads"); - let info = row.find("[Info]").expect("info"); - assert!(searches < downloads && downloads < info, "{row}"); - } - - #[test] - fn tab_and_shift_tab_walk_the_panes_in_legend_order() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - press(&mut tui, KeyCode::BackTab); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - } - - #[test] - fn w_hides_the_focused_pane_and_its_number_brings_it_back() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - - press(&mut tui, KeyCode::Char('w')); - - let screen = screen_of(&mut tui); - assert!(!screen.contains("[1] Searches"), "{screen}"); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(screen.contains("[3] Downloads"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); - // The transfers widen into the space. - let row = row_with(&screen, "[3] Downloads"); - assert!(row.starts_with('╭'), "{row}"); - - press(&mut tui, KeyCode::Char('1')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("[1] Searches"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - } - - #[test] - fn hiding_the_results_gives_the_row_the_whole_height() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - let screen = screen_of(&mut tui); - assert!(!screen.contains("[2] Results"), "{screen}"); - // The row now starts right under the status bar. - let status = screen - .lines() - .position(|line| line.contains("Status")) - .expect("status bar"); - let row = screen - .lines() - .position(|line| line.contains("[1] Searches")) - .expect("searches"); - assert_eq!(row, status + 3, "{screen}"); - } - - #[test] - fn the_last_pane_standing_cannot_be_hidden() { - // Results goes, then Downloads; Searches is what is left, and a - // third press leaves it there. - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('w')); - let screen = screen_of(&mut tui); - assert!(screen.contains("[1] Searches"), "{screen}"); - assert!(!screen.contains("[3] Downloads"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - } - - #[test] - fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('z')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(!screen.contains("[1] Searches"), "{screen}"); - assert!(!screen.contains("[3] Downloads"), "{screen}"); - assert!(!screen.contains("[Info]"), "{screen}"); - assert!(screen.contains("[z → unzoom]"), "{screen}"); - - // Tab while zoomed switches which pane fills the window. - press(&mut tui, KeyCode::Tab); - let screen = screen_of(&mut tui); - assert!(screen.contains("[3] Downloads"), "{screen}"); - assert!(!screen.contains("[2] Results"), "{screen}"); - - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.layout.zoomed); - let screen = screen_of(&mut tui); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(screen.contains("[Info]"), "{screen}"); - - press(&mut tui, KeyCode::Char('z')); - press(&mut tui, KeyCode::Char('z')); - assert!(!tui.state.layout.zoomed, "z toggles"); - } - - #[test] - fn a_search_brings_a_hidden_results_pane_back() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - assert!(!tui.state.layout.is_visible(FocusedPane::Results)); - - press(&mut tui, KeyCode::Char('s')); - press(&mut tui, KeyCode::Char('x')); - press(&mut tui, KeyCode::Enter); - - assert!(tui.state.layout.is_visible(FocusedPane::Results)); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - } - - #[test] - fn question_mark_opens_the_keys_list_and_closes_it_again() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('?')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("Keys"), "{screen}"); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - assert!(screen.contains("clear every finished one"), "{screen}"); - - // Other keys are swallowed while it is open. - press(&mut tui, KeyCode::Char('q')); - assert!(tui.state.exit.is_none(), "q closes the list, not the app"); - assert!(!tui.state.show_help); - - press(&mut tui, KeyCode::Char('?')); - press(&mut tui, KeyCode::Char('w')); - assert!(tui.state.show_help, "w did not reach the panes"); - assert!(tui.state.layout.is_visible(FocusedPane::Results)); - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.show_help); - } - - #[test] - fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('?')); - - let screen = screen_sized(&mut tui, 80, 24); - let panes = row_with(&screen, "Panes"); - assert!(!panes.contains("Searches"), "one column: {panes}"); - assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); - assert!(!screen.contains("next room, chat or user"), "{screen}"); - - press(&mut tui, KeyCode::End); - let screen = screen_sized(&mut tui, 80, 24); - assert!(screen.contains("next room, chat or user"), "{screen}"); - assert!(!screen.contains("next / previous pane"), "{screen}"); - - press(&mut tui, KeyCode::Home); - let screen = screen_sized(&mut tui, 80, 24); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - - // A wide window shows both columns at once, nothing to scroll. It - // has to be tall enough to hold the taller column outright; in a - // shorter one End follows the tail and the headings scroll off. - press(&mut tui, KeyCode::End); - let screen = screen_sized(&mut tui, 160, 44); - let panes = row_with(&screen, "Panes"); - assert!(panes.contains("Searches"), "two columns: {panes}"); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - } - - #[test] - fn a_click_where_a_pane_just_hid_does_not_focus_it() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - // Hidden, but not yet redrawn: the old area is still on record. - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Char('w')); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { - kind: ratatui::crossterm::event::MouseEventKind::Down( - ratatui::crossterm::event::MouseButton::Left, - ), - column: searches.x + 1, - row: searches.y + 1, - modifiers: KeyModifiers::NONE, - }); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - } - - #[test] - fn l_and_h_scroll_a_transfer_name_and_a_query_too() { - let mut tui = furnished_tui(); - tui.state.downloads[0].download.filename = "y".repeat(120); - tui.state.searches[0].query = "z".repeat(60); - let _ = screen_of(&mut tui); - - tui.state.focused_pane = FocusedPane::Downloads; - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.downloads_name_offset, 8); - press(&mut tui, KeyCode::Char('$')); - let end = tui.state.downloads_name_offset; - assert!(end > 8, "the end is past a step: {end}"); - let screen = screen_of(&mut tui); - assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}"); - press(&mut tui, KeyCode::Char('0')); - assert_eq!(tui.state.downloads_name_offset, 0); - - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Right); - assert_eq!(tui.state.searches_query_offset, 8); - press(&mut tui, KeyCode::End); - assert_eq!( - tui.state.searches_table_state.selected(), - Some(0), - "End is still a row key, $ is the name key" - ); - press(&mut tui, KeyCode::Char('$')); - assert!(tui.state.searches_query_offset > 8); - let screen = screen_of(&mut tui); - assert!(screen.contains("…zzzz"), "{screen}"); - assert!( - tui.state.results_name_offset == 0, - "each list scrolls on its own" - ); - } - - 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); - } - - #[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"); - } - - #[test] - fn slash_and_u_filter_a_rooms_log_and_its_members() { - let mut tui = attach(Arc::new(TalkativeSession::default())); - in_a_room(&mut tui, 30); - press(&mut tui, KeyCode::Char('u')); - for c in "user2".chars() { - press(&mut tui, KeyCode::Char(c)); - } - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Char('j')); - assert_eq!( - tui.state.rooms.selected_user().as_deref(), - Some("user21"), - "j moves within the matching members" - ); - let screen = screen_of(&mut tui); - assert!(screen.contains("Users 10/30"), "{screen}"); - - press(&mut tui, KeyCode::Char('/')); - for c in "line 01".chars() { - press(&mut tui, KeyCode::Char(c)); - } - let screen = screen_of(&mut tui); - assert!(screen.contains("line 010"), "{screen}"); - assert!(!screen.contains("line 029"), "{screen}"); - assert!(screen.contains("filter: line 01_"), "{screen}"); - ctrl(&mut tui, 'u'); - assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Esc); - assert!(tui.state.rooms.log_filter.is_empty()); - assert!(tui.state.rooms.user_filter.is_empty()); - assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); - press(&mut tui, KeyCode::Esc); - assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); - } - - #[test] - fn slash_filters_the_open_conversation() { - let mut tui = attach(Arc::new(TalkativeSession::default())); - for text in ["hello there", "see you", "hello again"] { - tui.state.messages.push(crate::models::ChatMessage { - direction: MessageDirection::Incoming, - peer: "bob".to_string(), - text: text.to_string(), - at: chrono::Local::now(), - }); - } - tui.state.chat_peer = Some("bob".to_string()); - tui.state.show_messages = true; - press(&mut tui, KeyCode::Char('/')); - for c in "hello".chars() { - press(&mut tui, KeyCode::Char(c)); - } - let screen = screen_of(&mut tui); - assert!(screen.contains("hello again"), "{screen}"); - assert!(!screen.contains("see you"), "{screen}"); - press(&mut tui, KeyCode::Enter); - press(&mut tui, KeyCode::Esc); - assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.show_messages); - } - - 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); - } - - #[test] - fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { - let mut tui = with_session(TalkativeSession::default()); - in_a_room(&mut tui, 120); - - let screen = screen_of(&mut tui); - assert!(screen.contains("line 119"), "tails by default: {screen}"); - assert!(!screen.contains("line 000"), "{screen}"); - - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(!screen.contains("line 119"), "scrolled back: {screen}"); - - press(&mut tui, KeyCode::Home); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 000"), "the oldest: {screen}"); - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 000"), "clamped at the top: {screen}"); - - press(&mut tui, KeyCode::End); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 119"), "{screen}"); - - // The member list keeps its own keys, and ctrl-x is not x. - press(&mut tui, KeyCode::Down); - assert_eq!(tui.state.rooms.user_selected, 1); - ctrl(&mut tui, 'x'); - assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); - } - - #[test] - fn page_keys_move_through_the_room_list() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::List( - (0..60) - .map(|i| soulseek_rs::types::RoomInfo { - name: format!("room{i:02}"), - user_count: 60 - i, - }) - .collect(), - ), - None, - ); - tui.state.show_rooms = true; - let _ = screen_of(&mut tui); - - press(&mut tui, KeyCode::End); - assert_eq!(tui.state.rooms.list_selected, 59); - press(&mut tui, KeyCode::PageUp); - assert!(tui.state.rooms.list_selected < 59); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(tui.state.rooms.list_selected, 0); - ctrl(&mut tui, 'f'); - assert!(tui.state.rooms.list_selected > 1); - } - - #[test] - fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { - let mut tui = tui((0..80) - .map(|i| ChatMessageDto { - peer: "bob".into(), - outgoing: i % 2 == 0, - text: format!("msg {i:03}"), - at: i, - }) - .chain(std::iter::once(ChatMessageDto { - peer: "carol".into(), - outgoing: false, - text: "hello".into(), - at: 100, - })) - .collect()); - tui.state.chat_peer = Some("bob".to_string()); - tui.state.show_messages = true; - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 079"), "{screen}"); - - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); - press(&mut tui, KeyCode::Char('g')); - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 000"), "{screen}"); - - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.active_chat_peer(), Some("carol")); - assert!( - tui.state.chat_view.following(), - "a fresh chat starts at its end" - ); - press(&mut tui, KeyCode::BackTab); - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 079"), "{screen}"); - } - - #[test] - fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { - let mut tui = furnished_tui(); - let screen = screen_of(&mut tui); - assert!(screen.contains("[q → quit]"), "{screen}"); - let bar_rows = - screen.lines().filter(|line| line.contains(" → ")).count(); - assert_eq!(bar_rows, 2, "{screen}"); - - let wide = screen_sized(&mut tui, 300, 40); - let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count(); - assert_eq!(bar_rows, 1, "{wide}"); - } - - #[test] - fn the_searches_pane_lists_its_clear_all_and_rerun_keys() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - let screen = screen_sized(&mut tui, 300, 40); - assert!(screen.contains("[C → clear all]"), "{screen}"); - assert!(screen.contains("[S → search again]"), "{screen}"); - press(&mut tui, KeyCode::Char('?')); - let screen = screen_of(&mut tui); - assert!(screen.contains("run the search again"), "{screen}"); - } - - #[test] - fn the_downloads_pane_lists_its_clear_everything_key() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Downloads; - let screen = screen_sized(&mut tui, 300, 40); - assert!(screen.contains("[c → clear finished]"), "{screen}"); - assert!(screen.contains("[C → clear all]"), "{screen}"); - press(&mut tui, KeyCode::Char('?')); - let screen = screen_of(&mut tui); - assert!(screen.contains("cancelling the live ones"), "{screen}"); - } - - #[test] - fn shift_w_brings_every_pane_back_and_leaves_zoom() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - assert!(!screen_sized(&mut tui, 300, 40).contains("[W → ")); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('z')); - assert!(tui.state.layout.zoomed); - assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); - let screen = screen_sized(&mut tui, 300, 40); - assert!(screen.contains("[W → reset layout]"), "{screen}"); - press(&mut tui, KeyCode::Char('W')); - assert!(!tui.state.layout.zoomed); - assert!( - FocusedPane::ALL - .iter() - .all(|p| tui.state.layout.is_visible(*p)) - ); - } - - #[test] - fn page_keys_work_in_the_transfers_and_searches_lists_too() { - let mut tui = furnished_tui(); - for i in 0..30 { - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", &format!("{i}.mp3")), - receiver: None, - }); - } - tui.state.focused_pane = FocusedPane::Downloads; - tui.state.downloads_table_state.select(Some(0)); - let _ = screen_of(&mut tui); // lays the panes out, which sets the page size - - press(&mut tui, KeyCode::End); - assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); - press(&mut tui, KeyCode::Home); - assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); - press(&mut tui, KeyCode::PageDown); - let page = tui.state.downloads_table_state.selected().expect("row"); - assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); - ctrl(&mut tui, 'd'); - assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); - assert!(tui.state.downloads_table_state.selected() > Some(page)); - - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Char('G')); - assert_eq!(tui.state.searches_table_state.selected(), Some(0)); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(tui.state.searches_table_state.selected(), Some(0)); - assert_eq!(tui.state.searches.len(), 1, "g and G only move"); - } - - #[test] - fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - let inside = ratatui::crossterm::event::MouseEvent { - kind: ratatui::crossterm::event::MouseEventKind::Down( - ratatui::crossterm::event::MouseButton::Left, - ), - column: searches.x + 1, - row: searches.y + 1, - modifiers: KeyModifiers::NONE, - }; - tui.handle_mouse_event(inside); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - - press(&mut tui, KeyCode::Char('w')); - let _ = screen_of(&mut tui); - tui.handle_mouse_event(inside); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - } - - #[test] - fn dragging_the_border_under_results_resizes_its_row() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let results = tui.state.results_pane_area.expect("laid out"); - let divider = tui.state.hsplit_divider.expect("a divider"); - let before = results.height; - let focused = tui.state.focused_pane; - - // Two drags: the row shrinks by the rows the pointer moved up. - mouse_drag_row( - &mut tui, - (results.x + 1, divider.y), - (results.x + 1, divider.y - 2), - ); - assert_eq!(tui.state.layout.top_height, Some(before - 2)); - assert_eq!( - tui.state.focused_pane, focused, - "a grab is not a focus click" - ); - - let _ = screen_of(&mut tui); - assert_eq!( - tui.state.results_pane_area.expect("laid out").height, - before - 2 - ); - - // A drag far past the row stops at the smallest pane, not zero. - mouse_drag_row( - &mut tui, - (results.x + 1, divider.y - 2), - (results.x + 1, 0), - ); - assert!(tui.state.layout.top_height.expect("resized") >= 3); - - press(&mut tui, KeyCode::Char('W')); - assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); - } - - #[test] - fn dragging_a_row_panes_right_edge_gives_it_width() { - use ratatui::crossterm::event::{MouseButton, MouseEventKind}; - - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - let seam = tui - .state - .vsplit_dividers - .iter() - .find(|(pane, _)| *pane == FocusedPane::Searches) - .map(|(_, rect)| *rect) - .expect("a seam beside Searches"); - tui.state.focused_pane = FocusedPane::Downloads; - - mouse_drag_row( - &mut tui, - (seam.x, searches.y + 1), - (seam.x + 4, searches.y + 1), - ); - assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - - // The widths are applied, and Info narrows to whatever is left. - let _ = screen_of(&mut tui); - assert_eq!( - tui.state.searches_pane_area.expect("laid out").width, - searches.width + 4 - ); - assert!(tui.state.info_pane_area.expect("laid out").width >= 3); - - // Releasing the button ends the drag: later motions just move the - // pointer. - mouse( - &mut tui, - MouseEventKind::Drag(MouseButton::Left), - seam.x + 40, - searches.y + 1, - ); - assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); - } - - #[test] - fn the_info_pane_describes_the_highlighted_result() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = - "@@x\\Music\\Album\\Second Track.mp3".to_string(); - tui.state.results_items[1].bitrate = Some(320); - tui.state.results_table_state.select(Some(1)); - - let screen = screen_of(&mut tui); - assert!(screen.contains("@@x/Music/Album"), "{screen}"); - assert!(screen.contains("Bitrate"), "{screen}"); - - tui.state.focused_pane = FocusedPane::Downloads; - let screen = screen_of(&mut tui); - assert!(!screen.contains("@@x/Music/Album"), "{screen}"); - } - - /// 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 { - 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, queries: &[&str]) -> MainTui { - let mut tui = attach(session); - let queries: Vec = - queries.iter().map(ToString::to_string).collect(); - restore_searches(&mut tui.state, &queries); - tui.state.focused_pane = FocusedPane::Searches; - tui - } - - #[test] - fn shift_s_runs_the_highlighted_search_again_in_its_place() { - // The list survives a restart, but a query on it was only ever a - // reminder: seeing what the network has now meant typing it again. - let session = Arc::new(TalkativeSession::default()); - let mut tui = - restored(session.clone(), &["boards of canada", "aphex twin"]); - tui.state.searches_table_state.select(Some(1)); - - press(&mut tui, KeyCode::Char('S')); - - assert_eq!( - searched(&session, 1), - vec!["aphex twin".to_string()], - "the highlighted query went out again, untyped" - ); - let queries: Vec<&str> = tui - .state - .searches - .iter() - .map(|s| s.query.as_str()) - .collect(); - assert_eq!( - queries, - ["boards of canada", "aphex twin"], - "it keeps its row: no duplicate, nothing moved" - ); - assert_eq!(tui.state.searches[1].status, SearchStatus::Active); - assert_eq!(tui.state.selected_search_index, Some(1)); - assert_eq!( - tui.state.focused_pane, - FocusedPane::Results, - "and the window turns to where the results will land" - ); - assert!(tui.state.results_items.is_empty()); - } - - #[test] - fn shift_s_stops_a_run_still_collecting_before_starting_over() { - let mut tui = furnished_tui(); - tui.state.searches[0].status = SearchStatus::Active; - tui.state.searches[0].start_time = std::time::Instant::now() - .checked_sub(Duration::from_secs(2)) - .expect("two seconds ago"); - let previous = tui.state.searches[0].cancel_flag.clone(); - tui.state.focused_pane = FocusedPane::Searches; - tui.state.searches_table_state.select(Some(0)); - - press(&mut tui, KeyCode::Char('S')); - - assert!( - previous.load(std::sync::atomic::Ordering::Relaxed), - "the worker that was collecting is told to stop" - ); - let fresh = &tui.state.searches[0]; - assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed)); - assert!(fresh.results.is_empty(), "stale rows do not linger"); - assert_eq!(tui.state.searches.len(), 1); - } - - #[test] - fn shift_s_is_only_a_key_over_the_searches_list() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = restored(session.clone(), &["aphex twin"]); - - // Over Results the same key means nothing, so a stray shift does not - // throw a finished search away. - tui.state.focused_pane = FocusedPane::Results; - press(&mut tui, KeyCode::Char('S')); - assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); - std::thread::sleep(Duration::from_millis(50)); - assert!(searched(&session, 0).is_empty(), "nothing went out"); - } - - #[test] - fn a_held_shift_s_is_one_search_not_one_per_repeat() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = restored(session.clone(), &["aphex twin"]); - press(&mut tui, KeyCode::Char('S')); - press(&mut tui, KeyCode::Char('1')); - press(&mut tui, KeyCode::Char('S')); - assert_eq!(searched(&session, 1).len(), 1); - std::thread::sleep(Duration::from_millis(50)); - assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored"); - } -} +mod tests; diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs new file mode 100644 index 0000000..bf16054 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -0,0 +1,509 @@ +mod shortcuts; + +use super::MainTui; +use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; +use crate::ui::panes::{ + InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, + render_download_info_pane, render_downloads_pane, render_results_pane, + render_rooms_pane, render_searches_pane, selected_transfer, +}; +use crate::ui::{ + GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, + pack_shortcuts, pane_block, plain_title, primary_style, + render_download_stats, warning_style, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Position, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::Paragraph, +}; +impl MainTui { + /// Draw the whole window. + pub fn render(&mut self, frame: &mut Frame) { + let shortcuts = self.shortcut_rows(frame.area().width); + let mut constraints = vec![ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + ]; + if self.state.command_bar_active { + constraints.push(Constraint::Length(3)); // Command bar + } + constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); + let main_chunks = Layout::vertical(constraints).split(frame.area()); + + let daemon = self.client.daemon_endpoint(); + render_download_stats( + frame, + main_chunks[0], + &self.state.downloads, + self.state.active_downloads_count, + daemon.as_deref(), + ); + + self.render_content(frame, main_chunks[1]); + + // Render command bar if active (vim-style, above shortcuts) + if self.state.command_bar_active { + self.render_command_bar(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[3], shortcuts); + } else { + self.render_shortcuts(frame, main_chunks[2], shortcuts); + } + + self.render_overlays(frame); + } + + /// Results across the top, where a long file name has the whole width; + /// Searches, Downloads and Info in a row underneath. A hidden pane drops + /// out of its row and the rest widen; a zoomed one has the area alone. + fn render_content(&mut self, frame: &mut Frame, area: Rect) { + // Only what is drawn can be clicked. + self.state.searches_pane_area = None; + self.state.results_pane_area = None; + self.state.downloads_pane_area = None; + self.state.info_pane_area = None; + self.state.hsplit_divider = None; + self.state.vsplit_dividers.clear(); + self.state.row_area = None; + + if self.state.layout.zoomed { + self.render_pane(frame, area, self.state.focused_pane); + return; + } + self.state.content_area = Some(area); + + // Transfers carry the most columns, so they get the most width. + let row_panes: Vec<(FocusedPane, u16)> = + [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] + .into_iter() + .filter(|(pane, _)| self.state.layout.is_visible(*pane)) + .collect(); + + let row_area = if self.state.layout.is_visible(FocusedPane::Results) { + // With only Info left in the row, the results deserve more of + // the height. + let row_weight = if row_panes.is_empty() { 1 } else { 2 }; + let top = match self.state.layout.top_height { + Some(rows) => Constraint::Length(shrink(rows, area.height)), + None => Constraint::Fill(3), + }; + let [top, bottom] = + Layout::vertical([top, Constraint::Fill(row_weight)]) + .areas(area); + // The two touching border rows between the halves; either can + // be dragged. + self.state.hsplit_divider = Some(Rect::new( + area.x, + top.y.saturating_add(top.height).saturating_sub(1), + area.width, + 2, + )); + self.render_pane(frame, top, FocusedPane::Results); + bottom + } else { + area + }; + self.state.row_area = Some(row_area); + + let mut constraints: Vec = row_panes + .iter() + .map(|(pane, weight)| { + let dragged = match *pane { + FocusedPane::Searches => self.state.layout.searches_width, + FocusedPane::Downloads => self.state.layout.downloads_width, + FocusedPane::Results => None, + }; + match dragged { + Some(cols) => { + Constraint::Length(shrink(cols, row_area.width)) + } + None => Constraint::Fill(*weight), + } + }) + .collect(); + constraints.push(Constraint::Fill(6)); // Info + let chunks = Layout::horizontal(constraints).split(row_area); + for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { + // The seam at this pane's right edge: its border column and the + // next pane's, both draggable. + self.state.vsplit_dividers.push(( + *pane, + Rect::new( + chunk.x.saturating_add(chunk.width).saturating_sub(1), + row_area.y, + 2, + row_area.height, + ), + )); + self.render_pane(frame, *chunk, *pane); + } + self.state.info_pane_area = Some(chunks[row_panes.len()]); + self.render_info_pane(frame, chunks[row_panes.len()]); + } + + fn render_pane( + &mut self, + frame: &mut Frame, + area: Rect, + pane: FocusedPane, + ) { + let focused = self.state.focused_pane == pane; + match pane { + FocusedPane::Searches => { + self.state.searches_pane_area = Some(area); + render_searches_pane( + frame, + area, + &self.state.searches, + &mut self.state.searches_table_state, + focused, + self.state.searches_query_offset, + ); + } + FocusedPane::Results => { + self.state.results_pane_area = Some(area); + self.render_results_pane(frame, area, focused); + } + FocusedPane::Downloads => { + self.state.downloads_pane_area = Some(area); + render_downloads_pane( + frame, + area, + &self.state.downloads, + &self.state.uploads, + &mut self.state.downloads_table_state, + focused, + self.state.downloads_name_offset, + ); + } + } + } + + fn render_results_pane( + &mut self, + frame: &mut Frame, + area: Rect, + focused: bool, + ) { + // When a filter is active the rendered rows are a subset, so the pane + // also needs the mapping back to unfiltered indices to render the + // selection checkboxes correctly. + let (results_items, results_original_indices) = + if self.state.results_filter_query.is_empty() { + (&self.state.results_items, None) + } else { + ( + &self.state.results_filtered_items, + Some(self.state.results_filtered_indices.as_slice()), + ) + }; + + let active_search_query = self + .state + .selected_search_index + .and_then(|idx| self.state.searches.get(idx)) + .map(|search| search.query.as_str()); + + render_results_pane( + frame, + area, + ResultsPaneParams { + items: results_items, + table_state: &mut self.state.results_table_state, + selected_indices: &self.state.results_selected_indices, + original_indices: results_original_indices, + filter_query: &self.state.results_filter_query, + is_filtering: self.state.results_is_filtering, + focused, + active_search_query, + name_offset: self.state.results_name_offset, + }, + ); + } + + /// The Info pane follows the focus: the highlighted result, or the + /// highlighted transfer. + fn render_info_pane(&self, frame: &mut Frame, area: Rect) { + let selected = if self.state.focused_pane == FocusedPane::Results { + self.highlighted_result().map(InfoSubject::Result) + } else { + selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) + }; + render_download_info_pane( + frame, + area, + selected, + matches!( + self.state.focused_pane, + FocusedPane::Results | FocusedPane::Downloads + ), + ); + } + fn render_overlays(&mut self, frame: &mut Frame) { + // Messages inbox overlays everything when open. + if self.state.show_messages { + self.render_messages_popup(frame); + } + + // Browse tree overlays everything when open. + if self.state.show_browse && !self.state.browse.is_empty() { + let area = centered_rect(80, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_browse_pane( + frame, + area, + &self.state.browse, + &mut self.state.browse_table_state, + self.spinner_state, + &self.state.downloads, + ); + } + + // Settings popup overlays everything when open. + if self.state.settings.is_some() { + self.render_settings_popup(frame); + } + + // Chat rooms overlay everything when open. + if self.state.show_rooms { + let area = centered_rect(85, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_rooms_pane( + frame, + area, + &mut self.state.rooms, + &mut self.state.rooms_list_table_state, + ); + } + + // The keys list only opens from the main view, so it is on top of + // nothing; drawn last so that stays true if that ever changes. + if self.state.show_help { + self.render_help_popup(frame); + } + } + + fn render_settings_popup(&self, frame: &mut Frame) { + use crate::models::{SettingsMode, SettingsRow}; + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + + let selected = settings.selected_row(); + let mut lines: Vec = Vec::new(); + let entry = |on: bool, label: &str, value: String| { + Line::from(vec![ + Span::styled(if on { "> " } else { " " }, accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + ]) + }; + let typing = |label: &str, value: String| { + Line::from(vec![ + Span::styled("> ", accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + Span::styled(GLYPH_CURSOR, accent_style()), + ]) + }; + let section = |name: &str| Line::styled(name.to_string(), info_style()); + + // One column of labels, padded here rather than by hand in each + // string. + let field = |label: &str, value: String| { + entry(false, &format!(" {label:<14}"), value) + }; + let account = &settings.account; + lines.push(section("Account")); + lines.push(field("Signed in as", account.username.clone())); + lines.push(field("Sharing", account.shares.clone())); + lines.push(field( + "Reachable on", + account.listen_port.map_or_else( + || "not accepting connections".to_string(), + |port| format!("port {port}"), + ), + )); + if let Some(daemon) = &account.daemon { + lines.push(field("Daemon", daemon.clone())); + } + lines.push(Line::from("")); + + lines.push(match settings.mode { + SettingsMode::NewPassword => { + typing("New password: ", mask(&settings.input)) + } + SettingsMode::RepeatPassword(_) => { + typing("Repeat it: ", mask(&settings.input)) + } + _ => entry( + selected == SettingsRow::ChangePassword, + "Change password…", + String::new(), + ), + }); + if settings.mode == SettingsMode::ConfirmingLogout { + lines.push(entry( + true, + &format!("Log out of {}? ", account.username), + "y / n".to_string(), + )); + } else if settings.can_log_out() { + lines.push(entry( + selected == SettingsRow::Logout, + "Log out…", + String::new(), + )); + } + lines.push(Line::from("")); + + lines.push(section("Folders")); + lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + typing("Download folder: ", settings.input.clone()) + } else { + entry( + selected == SettingsRow::DownloadDir, + "Download folder: ", + settings.download_dir.clone(), + ) + }); + lines.push(Line::styled( + format!("Shared folders ({}):", settings.share_dirs.len()), + dimmed_style(), + )); + for (i, dir) in settings.share_dirs.iter().enumerate() { + lines.push(entry( + selected == SettingsRow::Share(i), + "", + dir.clone(), + )); + } + if settings.share_dirs.is_empty() { + lines.push(Line::styled( + " (nothing shared — press 'a' to add a folder)", + dimmed_style(), + )); + } + if settings.mode == SettingsMode::AddingShare { + lines.push(typing(" Add share: ", settings.input.clone())); + } + if let Some(status) = &settings.status { + lines.push(Line::from("")); + lines.push(Line::styled(status.clone(), warning_style())); + } + + let block = pane_block(true).title(plain_title("Settings", true)); + frame.render_widget( + ratatui::widgets::Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + } + + fn render_messages_popup(&mut self, frame: &mut Frame) { + // The per-conversation chat box supersedes the old flat message list; + // it renders the messages and the compose line itself. + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + self.state.popup_area = Some(area); + let own = self.client.username(); + render_chat_pane(frame, area, &mut self.state, &own); + } + fn render_command_bar(&self, frame: &mut Frame, area: Rect) { + let prefix = match self.state.command_bar_mode { + CommandBarMode::Search => "search: ", + CommandBarMode::Message => "message (to: recipient text): ", + CommandBarMode::Browse => "browse user: ", + }; + let block = pane_block(true); + // Derive from the block's inner rect so borders and padding stay in + // one place; the cursor follows whatever the pane reserves. + let inner = block.inner(area); + let prefix_width = prefix.chars().count() as u16; + let input_width = inner.width.saturating_sub(prefix_width); + let (visible_input, cursor_column) = visible_input_at_cursor( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + input_width, + ); + let command_line = Line::from(vec![ + Span::styled(prefix, accent_style()), + Span::styled(visible_input, primary_style()), + ]); + + frame.render_widget(Paragraph::new(command_line).block(block), area); + + if inner.width > 0 && inner.height > 0 { + let cursor_x = inner + .x + .saturating_add(prefix_width) + .saturating_add(cursor_column) + .min(inner.x.saturating_add(inner.width.saturating_sub(1))); + frame.set_cursor_position(Position::new(cursor_x, inner.y)); + } + } +} + +/// A dragged pane size kept inside the space the terminal now offers: never +/// below [`MIN_PANE`], and never so big that what shares the split with it +/// drops below `MIN_PANE` too. +pub(super) fn shrink(dragged: u16, total: u16) -> u16 { + let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); + dragged.max(MIN_PANE).min(max) +} + +/// The smallest pane a drag can leave anyone: a border and a row or column +/// of content each side. +pub(super) const MIN_PANE: u16 = 3; +/// A `Rect` centered within `area`, sized to the given percentages. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} +fn visible_input_at_cursor( + input: &str, + cursor_position: usize, + width: u16, +) -> (String, u16) { + if width == 0 { + return (String::new(), 0); + } + + let cursor_position = input.floor_char_boundary(cursor_position); + let cursor_character_index = input[..cursor_position].chars().count(); + let max_cursor_column = usize::from(width.saturating_sub(1)); + let start_character_index = + cursor_character_index.saturating_sub(max_cursor_column); + + let visible_input = input + .chars() + .skip(start_character_index) + .take(usize::from(width)) + .collect(); + let cursor_column = cursor_character_index + .saturating_sub(start_character_index) + .min(max_cursor_column); + + (visible_input, cursor_column as u16) +} diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs similarity index 52% rename from soulseek-rs/src/ui/main_tui/render.rs rename to soulseek-rs/src/ui/main_tui/render/shortcuts.rs index a397e4b..3c96557 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs @@ -1,21 +1,15 @@ -use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; -use crate::ui::panes::{ - InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, - render_download_info_pane, render_downloads_pane, render_results_pane, - render_rooms_pane, render_searches_pane, selected_transfer, -}; -use crate::ui::{ - GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, - pack_shortcuts, pane_block, plain_title, primary_style, - render_download_stats, warning_style, -}; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Position, Rect}, - style::Modifier, - text::{Line, Span}, - widgets::Paragraph, +//! The keys the window advertises: the shortcut bar along the foot, the `?` +//! overlay behind it, and the unread badge that rides in the bar. +//! +//! A child module of `render`, so it still reaches the window's state and +//! `render`'s own helpers directly; only what `render` calls back into is +//! widened to `pub(super)`. + +use super::{ + Alignment, CommandBarMode, Constraint, FocusedPane, Frame, Layout, Line, + MainTui, Modifier, PaneLayout, Paragraph, Rect, RoomsView, Span, + accent_style, body_style, dimmed_style, info_style, pack_shortcuts, + pane_block, plain_title, warning_style, }; /// The keys overlay, grouped by where a key applies. Two columns, because a @@ -182,236 +176,10 @@ fn settings_shortcuts( } impl MainTui { - /// Draw the whole window. - pub fn render(&mut self, frame: &mut Frame) { - let shortcuts = self.shortcut_rows(frame.area().width); - let mut constraints = vec![ - Constraint::Length(3), // Status bar - Constraint::Fill(1), // Main content - ]; - if self.state.command_bar_active { - constraints.push(Constraint::Length(3)); // Command bar - } - constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); - let main_chunks = Layout::vertical(constraints).split(frame.area()); - - let daemon = self.client.daemon_endpoint(); - render_download_stats( - frame, - main_chunks[0], - &self.state.downloads, - self.state.active_downloads_count, - daemon.as_deref(), - ); - - self.render_content(frame, main_chunks[1]); - - // Render command bar if active (vim-style, above shortcuts) - if self.state.command_bar_active { - self.render_command_bar(frame, main_chunks[2]); - self.render_shortcuts(frame, main_chunks[3], shortcuts); - } else { - self.render_shortcuts(frame, main_chunks[2], shortcuts); - } - - self.render_overlays(frame); - } - - /// Results across the top, where a long file name has the whole width; - /// Searches, Downloads and Info in a row underneath. A hidden pane drops - /// out of its row and the rest widen; a zoomed one has the area alone. - fn render_content(&mut self, frame: &mut Frame, area: Rect) { - // Only what is drawn can be clicked. - self.state.searches_pane_area = None; - self.state.results_pane_area = None; - self.state.downloads_pane_area = None; - self.state.info_pane_area = None; - self.state.hsplit_divider = None; - self.state.vsplit_dividers.clear(); - self.state.row_area = None; - - if self.state.layout.zoomed { - self.render_pane(frame, area, self.state.focused_pane); - return; - } - self.state.content_area = Some(area); - - // Transfers carry the most columns, so they get the most width. - let row_panes: Vec<(FocusedPane, u16)> = - [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] - .into_iter() - .filter(|(pane, _)| self.state.layout.is_visible(*pane)) - .collect(); - - let row_area = if self.state.layout.is_visible(FocusedPane::Results) { - // With only Info left in the row, the results deserve more of - // the height. - let row_weight = if row_panes.is_empty() { 1 } else { 2 }; - let top = match self.state.layout.top_height { - Some(rows) => Constraint::Length(shrink(rows, area.height)), - None => Constraint::Fill(3), - }; - let [top, bottom] = - Layout::vertical([top, Constraint::Fill(row_weight)]) - .areas(area); - // The two touching border rows between the halves; either can - // be dragged. - self.state.hsplit_divider = Some(Rect::new( - area.x, - top.y.saturating_add(top.height).saturating_sub(1), - area.width, - 2, - )); - self.render_pane(frame, top, FocusedPane::Results); - bottom - } else { - area - }; - self.state.row_area = Some(row_area); - - let mut constraints: Vec = row_panes - .iter() - .map(|(pane, weight)| { - let dragged = match *pane { - FocusedPane::Searches => self.state.layout.searches_width, - FocusedPane::Downloads => self.state.layout.downloads_width, - FocusedPane::Results => None, - }; - match dragged { - Some(cols) => { - Constraint::Length(shrink(cols, row_area.width)) - } - None => Constraint::Fill(*weight), - } - }) - .collect(); - constraints.push(Constraint::Fill(6)); // Info - let chunks = Layout::horizontal(constraints).split(row_area); - for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { - // The seam at this pane's right edge: its border column and the - // next pane's, both draggable. - self.state.vsplit_dividers.push(( - *pane, - Rect::new( - chunk.x.saturating_add(chunk.width).saturating_sub(1), - row_area.y, - 2, - row_area.height, - ), - )); - self.render_pane(frame, *chunk, *pane); - } - self.state.info_pane_area = Some(chunks[row_panes.len()]); - self.render_info_pane(frame, chunks[row_panes.len()]); - } - - fn render_pane( - &mut self, - frame: &mut Frame, - area: Rect, - pane: FocusedPane, - ) { - let focused = self.state.focused_pane == pane; - match pane { - FocusedPane::Searches => { - self.state.searches_pane_area = Some(area); - render_searches_pane( - frame, - area, - &self.state.searches, - &mut self.state.searches_table_state, - focused, - self.state.searches_query_offset, - ); - } - FocusedPane::Results => { - self.state.results_pane_area = Some(area); - self.render_results_pane(frame, area, focused); - } - FocusedPane::Downloads => { - self.state.downloads_pane_area = Some(area); - render_downloads_pane( - frame, - area, - &self.state.downloads, - &self.state.uploads, - &mut self.state.downloads_table_state, - focused, - self.state.downloads_name_offset, - ); - } - } - } - - fn render_results_pane( - &mut self, - frame: &mut Frame, - area: Rect, - focused: bool, - ) { - // When a filter is active the rendered rows are a subset, so the pane - // also needs the mapping back to unfiltered indices to render the - // selection checkboxes correctly. - let (results_items, results_original_indices) = - if self.state.results_filter_query.is_empty() { - (&self.state.results_items, None) - } else { - ( - &self.state.results_filtered_items, - Some(self.state.results_filtered_indices.as_slice()), - ) - }; - - let active_search_query = self - .state - .selected_search_index - .and_then(|idx| self.state.searches.get(idx)) - .map(|search| search.query.as_str()); - - render_results_pane( - frame, - area, - ResultsPaneParams { - items: results_items, - table_state: &mut self.state.results_table_state, - selected_indices: &self.state.results_selected_indices, - original_indices: results_original_indices, - filter_query: &self.state.results_filter_query, - is_filtering: self.state.results_is_filtering, - focused, - active_search_query, - name_offset: self.state.results_name_offset, - }, - ); - } - - /// The Info pane follows the focus: the highlighted result, or the - /// highlighted transfer. - fn render_info_pane(&self, frame: &mut Frame, area: Rect) { - let selected = if self.state.focused_pane == FocusedPane::Results { - self.highlighted_result().map(InfoSubject::Result) - } else { - selected_transfer( - self.state.downloads_table_state.selected(), - &self.state.downloads, - &self.state.uploads, - ) - }; - render_download_info_pane( - frame, - area, - selected, - matches!( - self.state.focused_pane, - FocusedPane::Results | FocusedPane::Downloads - ), - ); - } - /// The keys list, two columns side by side where the terminal is wide /// enough and one on top of the other where it is not, scrolling when /// even that is taller than the window. - fn render_help_popup(&mut self, frame: &mut Frame) { + pub(super) fn render_help_popup(&mut self, frame: &mut Frame) { let screen = frame.area(); // Borders and padding on either side of the text. let frame_width = 4; @@ -455,182 +223,6 @@ impl MainTui { } } - fn render_overlays(&mut self, frame: &mut Frame) { - // Messages inbox overlays everything when open. - if self.state.show_messages { - self.render_messages_popup(frame); - } - - // Browse tree overlays everything when open. - if self.state.show_browse && !self.state.browse.is_empty() { - let area = centered_rect(80, 80, frame.area()); - self.state.popup_area = Some(area); - frame.render_widget(ratatui::widgets::Clear, area); - render_browse_pane( - frame, - area, - &self.state.browse, - &mut self.state.browse_table_state, - self.spinner_state, - &self.state.downloads, - ); - } - - // Settings popup overlays everything when open. - if self.state.settings.is_some() { - self.render_settings_popup(frame); - } - - // Chat rooms overlay everything when open. - if self.state.show_rooms { - let area = centered_rect(85, 80, frame.area()); - self.state.popup_area = Some(area); - frame.render_widget(ratatui::widgets::Clear, area); - render_rooms_pane( - frame, - area, - &mut self.state.rooms, - &mut self.state.rooms_list_table_state, - ); - } - - // The keys list only opens from the main view, so it is on top of - // nothing; drawn last so that stays true if that ever changes. - if self.state.show_help { - self.render_help_popup(frame); - } - } - - fn render_settings_popup(&self, frame: &mut Frame) { - use crate::models::{SettingsMode, SettingsRow}; - let Some(settings) = self.state.settings.as_ref() else { - return; - }; - let area = centered_rect(70, 60, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - - let selected = settings.selected_row(); - let mut lines: Vec = Vec::new(); - let entry = |on: bool, label: &str, value: String| { - Line::from(vec![ - Span::styled(if on { "> " } else { " " }, accent_style()), - Span::styled(label.to_string(), dimmed_style()), - Span::styled(value, primary_style()), - ]) - }; - let typing = |label: &str, value: String| { - Line::from(vec![ - Span::styled("> ", accent_style()), - Span::styled(label.to_string(), dimmed_style()), - Span::styled(value, primary_style()), - Span::styled(GLYPH_CURSOR, accent_style()), - ]) - }; - let section = |name: &str| Line::styled(name.to_string(), info_style()); - - // One column of labels, padded here rather than by hand in each - // string. - let field = |label: &str, value: String| { - entry(false, &format!(" {label:<14}"), value) - }; - let account = &settings.account; - lines.push(section("Account")); - lines.push(field("Signed in as", account.username.clone())); - lines.push(field("Sharing", account.shares.clone())); - lines.push(field( - "Reachable on", - account.listen_port.map_or_else( - || "not accepting connections".to_string(), - |port| format!("port {port}"), - ), - )); - if let Some(daemon) = &account.daemon { - lines.push(field("Daemon", daemon.clone())); - } - lines.push(Line::from("")); - - lines.push(match settings.mode { - SettingsMode::NewPassword => { - typing("New password: ", mask(&settings.input)) - } - SettingsMode::RepeatPassword(_) => { - typing("Repeat it: ", mask(&settings.input)) - } - _ => entry( - selected == SettingsRow::ChangePassword, - "Change password…", - String::new(), - ), - }); - if settings.mode == SettingsMode::ConfirmingLogout { - lines.push(entry( - true, - &format!("Log out of {}? ", account.username), - "y / n".to_string(), - )); - } else if settings.can_log_out() { - lines.push(entry( - selected == SettingsRow::Logout, - "Log out…", - String::new(), - )); - } - lines.push(Line::from("")); - - lines.push(section("Folders")); - lines.push(if settings.mode == SettingsMode::EditingDownloadDir { - typing("Download folder: ", settings.input.clone()) - } else { - entry( - selected == SettingsRow::DownloadDir, - "Download folder: ", - settings.download_dir.clone(), - ) - }); - lines.push(Line::styled( - format!("Shared folders ({}):", settings.share_dirs.len()), - dimmed_style(), - )); - for (i, dir) in settings.share_dirs.iter().enumerate() { - lines.push(entry( - selected == SettingsRow::Share(i), - "", - dir.clone(), - )); - } - if settings.share_dirs.is_empty() { - lines.push(Line::styled( - " (nothing shared — press 'a' to add a folder)", - dimmed_style(), - )); - } - if settings.mode == SettingsMode::AddingShare { - lines.push(typing(" Add share: ", settings.input.clone())); - } - if let Some(status) = &settings.status { - lines.push(Line::from("")); - lines.push(Line::styled(status.clone(), warning_style())); - } - - let block = pane_block(true).title(plain_title("Settings", true)); - frame.render_widget( - ratatui::widgets::Paragraph::new(lines) - .block(block) - .wrap(ratatui::widgets::Wrap { trim: false }), - area, - ); - } - - fn render_messages_popup(&mut self, frame: &mut Frame) { - // The per-conversation chat box supersedes the old flat message list; - // it renders the messages and the compose line itself. - let area = centered_rect(70, 60, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - self.state.popup_area = Some(area); - let own = self.client.username(); - render_chat_pane(frame, area, &mut self.state, &own); - } - /// Context shortcuts for the chat-rooms popup. fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.rooms.composing { @@ -829,7 +421,7 @@ impl MainTui { /// The legend packed into rows for a window `width` wide, so a narrow /// terminal grows the bar instead of cutting keys off its right edge. - fn shortcut_rows(&self, width: u16) -> Vec> { + pub(super) fn shortcut_rows(&self, width: u16) -> Vec> { // Borders and padding, and the unread badge's column when it shows. let badge = self.unread_badge().width() as u16; let reserved = 4 + if badge > 0 { badge + 1 } else { 0 }; @@ -848,7 +440,7 @@ impl MainTui { ) } - fn render_shortcuts( + pub(super) fn render_shortcuts( &self, frame: &mut Frame, area: Rect, @@ -888,57 +480,11 @@ impl MainTui { ); } } - - fn render_command_bar(&self, frame: &mut Frame, area: Rect) { - let prefix = match self.state.command_bar_mode { - CommandBarMode::Search => "search: ", - CommandBarMode::Message => "message (to: recipient text): ", - CommandBarMode::Browse => "browse user: ", - }; - let block = pane_block(true); - // Derive from the block's inner rect so borders and padding stay in - // one place; the cursor follows whatever the pane reserves. - let inner = block.inner(area); - let prefix_width = prefix.chars().count() as u16; - let input_width = inner.width.saturating_sub(prefix_width); - let (visible_input, cursor_column) = visible_input_at_cursor( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - input_width, - ); - let command_line = Line::from(vec![ - Span::styled(prefix, accent_style()), - Span::styled(visible_input, primary_style()), - ]); - - frame.render_widget(Paragraph::new(command_line).block(block), area); - - if inner.width > 0 && inner.height > 0 { - let cursor_x = inner - .x - .saturating_add(prefix_width) - .saturating_add(cursor_column) - .min(inner.x.saturating_add(inner.width.saturating_sub(1))); - frame.set_cursor_position(Position::new(cursor_x, inner.y)); - } - } } /// Columns between the two halves of the keys list. const HELP_GAP: u16 = 2; -/// A dragged pane size kept inside the space the terminal now offers: never -/// below [`MIN_PANE`], and never so big that what shares the split with it -/// drops below `MIN_PANE` too. -pub(super) fn shrink(dragged: u16, total: u16) -> u16 { - let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); - dragged.max(MIN_PANE).min(max) -} - -/// The smallest pane a drag can leave anyone: a border and a row or column -/// of content each side. -pub(super) const MIN_PANE: u16 = 3; - /// The widest key in a column, so the descriptions line up. fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { sections @@ -990,22 +536,6 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { lines } -/// A `Rect` centered within `area`, sized to the given percentages. -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let vertical = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vertical[1])[1] -} - /// Build the shortcuts-bar unread badge: `✉ N` for unread private messages and /// `💬 N` for unread chat-room messages. Returns an empty line when nothing is /// unread so the caller can skip it. @@ -1038,33 +568,6 @@ fn unread_indicator( Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style)) } -fn visible_input_at_cursor( - input: &str, - cursor_position: usize, - width: u16, -) -> (String, u16) { - if width == 0 { - return (String::new(), 0); - } - - let cursor_position = input.floor_char_boundary(cursor_position); - let cursor_character_index = input[..cursor_position].chars().count(); - let max_cursor_column = usize::from(width.saturating_sub(1)); - let start_character_index = - cursor_character_index.saturating_sub(max_cursor_column); - - let visible_input = input - .chars() - .skip(start_character_index) - .take(usize::from(width)) - .collect(); - let cursor_column = cursor_character_index - .saturating_sub(start_character_index) - .min(max_cursor_column); - - (visible_input, cursor_column as u16) -} - #[cfg(test)] mod tests { use super::unread_indicator; diff --git a/soulseek-rs/src/ui/main_tui/tests/browse.rs b/soulseek-rs/src/ui/main_tui/tests/browse.rs new file mode 100644 index 0000000..f50e313 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/browse.rs @@ -0,0 +1,172 @@ +//! The browse popup, over a peer's shares and over our own. + +use super::*; + +#[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"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs new file mode 100644 index 0000000..8381637 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -0,0 +1,386 @@ +//! The window itself: which panes are up, how big, and what the chrome +//! says while they move. + +use super::*; + +#[test] +fn the_results_pane_has_the_whole_width_to_itself() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + + let results = row_with(&screen, "[2] Results"); + assert!(results.starts_with('╭'), "{results}"); + assert!(results.trim_end().ends_with('╮'), "{results}"); + assert!(!results.contains("[1]"), "nothing beside it: {results}"); + + // The other three share the row underneath, in this order. + let row = row_with(&screen, "[1] Searches"); + let searches = row.find("[1]").expect("searches"); + let downloads = row.find("[3]").expect("downloads"); + let info = row.find("[Info]").expect("info"); + assert!(searches < downloads && downloads < info, "{row}"); +} + +#[test] +fn tab_and_shift_tab_walk_the_panes_in_legend_order() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + press(&mut tui, KeyCode::BackTab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn w_hides_the_focused_pane_and_its_number_brings_it_back() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + + press(&mut tui, KeyCode::Char('w')); + + let screen = screen_of(&mut tui); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); + // The transfers widen into the space. + let row = row_with(&screen, "[3] Downloads"); + assert!(row.starts_with('╭'), "{row}"); + + press(&mut tui, KeyCode::Char('1')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn hiding_the_results_gives_the_row_the_whole_height() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(!screen.contains("[2] Results"), "{screen}"); + // The row now starts right under the status bar. + let status = screen + .lines() + .position(|line| line.contains("Status")) + .expect("status bar"); + let row = screen + .lines() + .position(|line| line.contains("[1] Searches")) + .expect("searches"); + assert_eq!(row, status + 3, "{screen}"); +} + +#[test] +fn the_last_pane_standing_cannot_be_hidden() { + // Results goes, then Downloads; Searches is what is left, and a + // third press leaves it there. + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('z')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[Info]"), "{screen}"); + assert!(screen.contains("[z → unzoom]"), "{screen}"); + + // Tab while zoomed switches which pane fills the window. + press(&mut tui, KeyCode::Tab); + let screen = screen_of(&mut tui); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[2] Results"), "{screen}"); + + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.layout.zoomed); + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[Info]"), "{screen}"); + + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Char('z')); + assert!(!tui.state.layout.zoomed, "z toggles"); +} + +#[test] +fn a_search_brings_a_hidden_results_pane_back() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + assert!(!tui.state.layout.is_visible(FocusedPane::Results)); + + press(&mut tui, KeyCode::Char('s')); + press(&mut tui, KeyCode::Char('x')); + press(&mut tui, KeyCode::Enter); + + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn question_mark_opens_the_keys_list_and_closes_it_again() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("Keys"), "{screen}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + assert!(screen.contains("clear every finished one"), "{screen}"); + + // Other keys are swallowed while it is open. + press(&mut tui, KeyCode::Char('q')); + assert!(tui.state.exit.is_none(), "q closes the list, not the app"); + assert!(!tui.state.show_help); + + press(&mut tui, KeyCode::Char('?')); + press(&mut tui, KeyCode::Char('w')); + assert!(tui.state.show_help, "w did not reach the panes"); + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_help); +} + +#[test] +fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_sized(&mut tui, 80, 24); + let panes = row_with(&screen, "Panes"); + assert!(!panes.contains("Searches"), "one column: {panes}"); + assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); + assert!(!screen.contains("next room, chat or user"), "{screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("next room, chat or user"), "{screen}"); + assert!(!screen.contains("next / previous pane"), "{screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + + // A wide window shows both columns at once, nothing to scroll. It + // has to be tall enough to hold the taller column outright; in a + // shorter one End follows the tail and the headings scroll off. + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 160, 44); + let panes = row_with(&screen, "Panes"); + assert!(panes.contains("Searches"), "two columns: {panes}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); +} + +#[test] +fn a_click_where_a_pane_just_hid_does_not_focus_it() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + // Hidden, but not yet redrawn: the old area is still on record. + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('w')); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + assert!(screen.contains("[q → quit]"), "{screen}"); + let bar_rows = screen.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 2, "{screen}"); + + let wide = screen_sized(&mut tui, 300, 40); + let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count(); + assert_eq!(bar_rows, 1, "{wide}"); +} + +#[test] +fn the_searches_pane_lists_its_clear_all_and_rerun_keys() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[C → clear all]"), "{screen}"); + assert!(screen.contains("[S → search again]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("run the search again"), "{screen}"); +} + +#[test] +fn the_downloads_pane_lists_its_clear_everything_key() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[c → clear finished]"), "{screen}"); + assert!(screen.contains("[C → clear all]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("cancelling the live ones"), "{screen}"); +} + +#[test] +fn shift_w_brings_every_pane_back_and_leaves_zoom() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + assert!(!screen_sized(&mut tui, 300, 40).contains("[W → ")); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('z')); + assert!(tui.state.layout.zoomed); + assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[W → reset layout]"), "{screen}"); + press(&mut tui, KeyCode::Char('W')); + assert!(!tui.state.layout.zoomed); + assert!( + FocusedPane::ALL + .iter() + .all(|p| tui.state.layout.is_visible(*p)) + ); +} + +#[test] +fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let inside = ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }; + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + + press(&mut tui, KeyCode::Char('w')); + let _ = screen_of(&mut tui); + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn dragging_the_border_under_results_resizes_its_row() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let results = tui.state.results_pane_area.expect("laid out"); + let divider = tui.state.hsplit_divider.expect("a divider"); + let before = results.height; + let focused = tui.state.focused_pane; + + // Two drags: the row shrinks by the rows the pointer moved up. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y), + (results.x + 1, divider.y - 2), + ); + assert_eq!(tui.state.layout.top_height, Some(before - 2)); + assert_eq!( + tui.state.focused_pane, focused, + "a grab is not a focus click" + ); + + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.results_pane_area.expect("laid out").height, + before - 2 + ); + + // A drag far past the row stops at the smallest pane, not zero. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y - 2), + (results.x + 1, 0), + ); + assert!(tui.state.layout.top_height.expect("resized") >= 3); + + press(&mut tui, KeyCode::Char('W')); + assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); +} + +#[test] +fn dragging_a_row_panes_right_edge_gives_it_width() { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let seam = tui + .state + .vsplit_dividers + .iter() + .find(|(pane, _)| *pane == FocusedPane::Searches) + .map(|(_, rect)| *rect) + .expect("a seam beside Searches"); + tui.state.focused_pane = FocusedPane::Downloads; + + mouse_drag_row( + &mut tui, + (seam.x, searches.y + 1), + (seam.x + 4, searches.y + 1), + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + + // The widths are applied, and Info narrows to whatever is left. + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.searches_pane_area.expect("laid out").width, + searches.width + 4 + ); + assert!(tui.state.info_pane_area.expect("laid out").width >= 3); + + // Releasing the button ends the drag: later motions just move the + // pointer. + mouse( + &mut tui, + MouseEventKind::Drag(MouseButton::Left), + seam.x + 40, + searches.y + 1, + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); +} + +#[test] +fn the_info_pane_describes_the_highlighted_result() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = + "@@x\\Music\\Album\\Second Track.mp3".to_string(); + tui.state.results_items[1].bitrate = Some(320); + tui.state.results_table_state.select(Some(1)); + + let screen = screen_of(&mut tui); + assert!(screen.contains("@@x/Music/Album"), "{screen}"); + assert!(screen.contains("Bitrate"), "{screen}"); + + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_of(&mut tui); + assert!(!screen.contains("@@x/Music/Album"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs new file mode 100644 index 0000000..b203767 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -0,0 +1,560 @@ +//! 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, + downloads: Vec, + searches: std::sync::Mutex>, + /// What `try_get_search_results` hands back to the window. + results: std::sync::Mutex>, + /// Every query a window put on the wire, in order. + searched: std::sync::Mutex>, + /// 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>, + /// What `set_shared_directories` was last asked for, if anything. + shares_set: std::sync::Mutex>>, + /// What `change_password` was last asked for, if anything. + password_set: std::sync::Mutex>, + /// The share index this session serves to peers. + listing: std::sync::Mutex>, + download_cancelled: std::sync::Mutex>, + upload_cancelled: std::sync::Mutex>, + download_removed: std::sync::Mutex>, +} + +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 { + self.history.clone() + } + fn daemon_endpoint(&self) -> Option { + self.shared.then(|| "test-daemon".to_string()) + } + fn username(&self) -> String { + "tester".to_string() + } + fn listen_port(&self) -> Option { + None + } + fn session_loss(&self) -> Option { + None + } + fn search_with_cancel( + &self, + query: &str, + _timeout: Duration, + _cancel: Option>, + ) -> soulseek_rs::Result> { + self.searched + .lock() + .expect("not poisoned") + .push(query.to_string()); + Ok(Vec::new()) + } + fn get_search_results(&self, _key: &str) -> Vec { + Vec::new() + } + fn all_searches(&self) -> Vec { + 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> { + 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, + )> { + 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, + )> { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + fn get_all_downloads(&self) -> Vec { + 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 { + Vec::new() + } + fn take_upload_events(&self) -> Vec { + 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 { + 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 { + Vec::new() + } + fn room_member_stats( + &self, + _room: &str, + ) -> Vec { + Vec::new() + } + fn take_room_events(&self) -> Vec { + Vec::new() + } + fn send_private_message( + &self, + _u: &str, + _m: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_private_messages(&self) -> Vec { + Vec::new() + } + fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_browse_result( + &self, + _username: &str, + ) -> Option> { + 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 { + Vec::new() + } + fn user_info(&self, _username: &str) -> Option { + 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 { + self.listing.lock().expect("not poisoned").clone() + } + fn shared_directories(&self) -> Vec { + Vec::new() + } + fn set_shared_directories( + &self, + directories: Vec, + ) -> 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) -> MainTui { + with_session(TalkativeSession { + history, + shared: true, + ..TalkativeSession::default() + }) +} + +fn shared(searches: Vec) -> 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) -> MainTui { + MainTui::new( + session, + "/tmp".to_string(), + 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, 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 { + (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 { + 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 { + 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, queries: &[&str]) -> MainTui { + let mut tui = attach(session); + let queries: Vec = + queries.iter().map(ToString::to_string).collect(); + restore_searches(&mut tui.state, &queries); + tui.state.focused_pane = FocusedPane::Searches; + tui +} diff --git a/soulseek-rs/src/ui/main_tui/tests/navigation.rs b/soulseek-rs/src/ui/main_tui/tests/navigation.rs new file mode 100644 index 0000000..fca9606 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/navigation.rs @@ -0,0 +1,236 @@ +//! Moving through a list: rows, pages, and the sideways scroll that +//! walks a name too long for its column. + +use super::*; + +#[test] +fn end_and_home_jump_to_the_last_and_first_result() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), Some(0)); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(10)); + ctrl(&mut tui, 'f'); + assert_eq!(selected(&tui), Some(20)); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(19)); + ctrl(&mut tui, 'b'); + assert_eq!(selected(&tui), Some(9)); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(0)); + assert!( + !tui.state.show_browse && !tui.state.command_bar_active, + "ctrl-b pages up instead of opening browse" + ); +} + +#[test] +fn ctrl_d_and_ctrl_u_move_half_a_pane() { + let mut tui = results_tui(30); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + ctrl(&mut tui, 'u'); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn navigation_keys_do_nothing_without_results() { + let mut tui = results_tui(0); + tui.state.results_table_state.select(None); + press(&mut tui, KeyCode::End); + press(&mut tui, KeyCode::PageDown); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), None); +} + +#[test] +fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 8); + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.results_name_offset, 16); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 22); + press(&mut tui, KeyCode::Left); + assert_eq!(tui.state.results_name_offset, 14); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.results_name_offset, 0); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn scrolling_follows_the_highlighted_row_not_the_longest_one() { + let mut tui = results_tui(3); + tui.state.results_items[0].filename = "short.mp3".to_string(); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_items[2].filename = "short.mp3".to_string(); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + tui.state.results_table_state.select(Some(2)); + press(&mut tui, KeyCode::Char('h')); + assert_eq!( + tui.state.results_name_offset, 0, + "a step left lands within the row now highlighted" + ); +} + +#[test] +fn picking_another_search_starts_its_results_unscrolled() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + + tui.state.searches.push(crate::models::SearchEntry { + query: "other".to_string(), + status: SearchStatus::Active, + results: results(2), + known_files: 2, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Enter); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn ctrl_keys_are_ignored_outside_the_results_pane() { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "keep me".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); + ctrl(&mut tui, 'b'); + assert!(!tui.state.command_bar_active, "ctrl-b is not b"); + ctrl(&mut tui, 'q'); + assert!(tui.state.exit.is_none(), "ctrl-q is not q"); +} + +#[test] +fn ctrl_keys_page_while_a_filter_is_being_typed() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('m')); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + assert_eq!(tui.state.results_filter_query, "m", "no d appended"); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); +} + +#[test] +fn space_after_a_filter_that_matches_nothing_does_not_panic() { + let mut tui = results_tui(3); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Enter); + assert!(tui.state.results_filtered_items.is_empty()); + press(&mut tui, KeyCode::Char(' ')); + assert!(tui.state.results_selected_indices.is_empty()); +} + +#[test] +fn l_and_h_scroll_a_transfer_name_and_a_query_too() { + let mut tui = furnished_tui(); + tui.state.downloads[0].download.filename = "y".repeat(120); + tui.state.searches[0].query = "z".repeat(60); + let _ = screen_of(&mut tui); + + tui.state.focused_pane = FocusedPane::Downloads; + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.downloads_name_offset, 8); + press(&mut tui, KeyCode::Char('$')); + let end = tui.state.downloads_name_offset; + assert!(end > 8, "the end is past a step: {end}"); + let screen = screen_of(&mut tui); + assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}"); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.downloads_name_offset, 0); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.searches_query_offset, 8); + press(&mut tui, KeyCode::End); + assert_eq!( + tui.state.searches_table_state.selected(), + Some(0), + "End is still a row key, $ is the name key" + ); + press(&mut tui, KeyCode::Char('$')); + assert!(tui.state.searches_query_offset > 8); + let screen = screen_of(&mut tui); + assert!(screen.contains("…zzzz"), "{screen}"); + assert!( + tui.state.results_name_offset == 0, + "each list scrolls on its own" + ); +} + +#[test] +fn page_keys_work_in_the_transfers_and_searches_lists_too() { + let mut tui = furnished_tui(); + for i in 0..30 { + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", &format!("{i}.mp3")), + receiver: None, + }); + } + tui.state.focused_pane = FocusedPane::Downloads; + tui.state.downloads_table_state.select(Some(0)); + let _ = screen_of(&mut tui); // lays the panes out, which sets the page size + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); + press(&mut tui, KeyCode::Home); + assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::PageDown); + let page = tui.state.downloads_table_state.selected().expect("row"); + assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); + assert!(tui.state.downloads_table_state.selected() > Some(page)); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('G')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + assert_eq!(tui.state.searches.len(), 1, "g and G only move"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/rooms.rs b/soulseek-rs/src/ui/main_tui/tests/rooms.rs new file mode 100644 index 0000000..4cd7af0 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/rooms.rs @@ -0,0 +1,164 @@ +//! Rooms and conversations: their logs, their lists, and their filters. + +use super::*; + +#[test] +fn slash_and_u_filter_a_rooms_log_and_its_members() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + in_a_room(&mut tui, 30); + press(&mut tui, KeyCode::Char('u')); + for c in "user2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + assert_eq!( + tui.state.rooms.selected_user().as_deref(), + Some("user21"), + "j moves within the matching members" + ); + let screen = screen_of(&mut tui); + assert!(screen.contains("Users 10/30"), "{screen}"); + + press(&mut tui, KeyCode::Char('/')); + for c in "line 01".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("line 010"), "{screen}"); + assert!(!screen.contains("line 029"), "{screen}"); + assert!(screen.contains("filter: line 01_"), "{screen}"); + ctrl(&mut tui, 'u'); + assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.rooms.log_filter.is_empty()); + assert!(tui.state.rooms.user_filter.is_empty()); + assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); + press(&mut tui, KeyCode::Esc); + assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); +} + +#[test] +fn slash_filters_the_open_conversation() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + for text in ["hello there", "see you", "hello again"] { + tui.state.messages.push(crate::models::ChatMessage { + direction: MessageDirection::Incoming, + peer: "bob".to_string(), + text: text.to_string(), + at: chrono::Local::now(), + }); + } + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + press(&mut tui, KeyCode::Char('/')); + for c in "hello".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("hello again"), "{screen}"); + assert!(!screen.contains("see you"), "{screen}"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_messages); +} + +#[test] +fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { + let mut tui = with_session(TalkativeSession::default()); + in_a_room(&mut tui, 120); + + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "tails by default: {screen}"); + assert!(!screen.contains("line 000"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("line 119"), "scrolled back: {screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "the oldest: {screen}"); + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "clamped at the top: {screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "{screen}"); + + // The member list keeps its own keys, and ctrl-x is not x. + press(&mut tui, KeyCode::Down); + assert_eq!(tui.state.rooms.user_selected, 1); + ctrl(&mut tui, 'x'); + assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); +} + +#[test] +fn page_keys_move_through_the_room_list() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::List( + (0..60) + .map(|i| soulseek_rs::types::RoomInfo { + name: format!("room{i:02}"), + user_count: 60 - i, + }) + .collect(), + ), + None, + ); + tui.state.show_rooms = true; + let _ = screen_of(&mut tui); + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.rooms.list_selected, 59); + press(&mut tui, KeyCode::PageUp); + assert!(tui.state.rooms.list_selected < 59); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.rooms.list_selected, 0); + ctrl(&mut tui, 'f'); + assert!(tui.state.rooms.list_selected > 1); +} + +#[test] +fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { + let mut tui = tui((0..80) + .map(|i| ChatMessageDto { + peer: "bob".into(), + outgoing: i % 2 == 0, + text: format!("msg {i:03}"), + at: i, + }) + .chain(std::iter::once(ChatMessageDto { + peer: "carol".into(), + outgoing: false, + text: "hello".into(), + at: 100, + })) + .collect()); + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); + press(&mut tui, KeyCode::Char('g')); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 000"), "{screen}"); + + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.active_chat_peer(), Some("carol")); + assert!( + tui.state.chat_view.following(), + "a fresh chat starts at its end" + ); + press(&mut tui, KeyCode::BackTab); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/searches.rs b/soulseek-rs/src/ui/main_tui/tests/searches.rs new file mode 100644 index 0000000..0d40e7f --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/searches.rs @@ -0,0 +1,89 @@ +//! Running a search the list already holds. + +use super::*; + +#[test] +fn shift_s_runs_the_highlighted_search_again_in_its_place() { + // The list survives a restart, but a query on it was only ever a + // reminder: seeing what the network has now meant typing it again. + let session = Arc::new(TalkativeSession::default()); + let mut tui = + restored(session.clone(), &["boards of canada", "aphex twin"]); + tui.state.searches_table_state.select(Some(1)); + + press(&mut tui, KeyCode::Char('S')); + + assert_eq!( + searched(&session, 1), + vec!["aphex twin".to_string()], + "the highlighted query went out again, untyped" + ); + let queries: Vec<&str> = tui + .state + .searches + .iter() + .map(|s| s.query.as_str()) + .collect(); + assert_eq!( + queries, + ["boards of canada", "aphex twin"], + "it keeps its row: no duplicate, nothing moved" + ); + assert_eq!(tui.state.searches[1].status, SearchStatus::Active); + assert_eq!(tui.state.selected_search_index, Some(1)); + assert_eq!( + tui.state.focused_pane, + FocusedPane::Results, + "and the window turns to where the results will land" + ); + assert!(tui.state.results_items.is_empty()); +} + +#[test] +fn shift_s_stops_a_run_still_collecting_before_starting_over() { + let mut tui = furnished_tui(); + tui.state.searches[0].status = SearchStatus::Active; + tui.state.searches[0].start_time = std::time::Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("two seconds ago"); + let previous = tui.state.searches[0].cancel_flag.clone(); + tui.state.focused_pane = FocusedPane::Searches; + tui.state.searches_table_state.select(Some(0)); + + press(&mut tui, KeyCode::Char('S')); + + assert!( + previous.load(std::sync::atomic::Ordering::Relaxed), + "the worker that was collecting is told to stop" + ); + let fresh = &tui.state.searches[0]; + assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed)); + assert!(fresh.results.is_empty(), "stale rows do not linger"); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn shift_s_is_only_a_key_over_the_searches_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + + // Over Results the same key means nothing, so a stray shift does not + // throw a finished search away. + tui.state.focused_pane = FocusedPane::Results; + press(&mut tui, KeyCode::Char('S')); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); + std::thread::sleep(Duration::from_millis(50)); + assert!(searched(&session, 0).is_empty(), "nothing went out"); +} + +#[test] +fn a_held_shift_s_is_one_search_not_one_per_repeat() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + press(&mut tui, KeyCode::Char('S')); + press(&mut tui, KeyCode::Char('1')); + press(&mut tui, KeyCode::Char('S')); + assert_eq!(searched(&session, 1).len(), 1); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/session.rs b/soulseek-rs/src/ui/main_tui/tests/session.rs new file mode 100644 index 0000000..a409630 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/session.rs @@ -0,0 +1,289 @@ +//! A window over a session it did not start: what a daemon's state +//! looks like arriving in a window, and what the window pushes back. + +use super::*; + +#[test] +fn an_attached_session_opens_with_the_conversation_already_in_it() { + // A TUI attached to a daemon has no state file of its own; the + // conversation has to come from the session, or it starts blank while + // the daemon has been collecting messages for hours. + let tui = tui(vec![ + ChatMessageDto { + peer: "bob".into(), + outgoing: false, + text: "hi".into(), + at: 1, + }, + ChatMessageDto { + peer: "bob".into(), + outgoing: true, + text: "hello".into(), + at: 2, + }, + ]); + + assert_eq!(tui.state.messages.len(), 2); + assert_eq!(tui.state.messages[0].peer, "bob"); + assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); + assert_eq!( + tui.state.messages[1].direction, + MessageDirection::Outgoing, + "both halves of the conversation, not just what arrived" + ); + assert_eq!(tui.state.chat_peers(), ["bob"]); +} + +#[test] +fn a_window_shows_transfers_it_did_not_start() { + // Two windows on one daemon are two views of one queue. A transfer + // queued in the other one has to appear here, or closing a window + // would look like losing its downloads. + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "@@x\\theirs.mp3")], + shared: true, + ..TalkativeSession::default() + }); + assert!( + tui.state.downloads.is_empty(), + "nothing until the first poll" + ); + + tui.update_downloads(); + + assert_eq!(tui.state.downloads.len(), 1); + assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); + assert!( + tui.state.downloads[0].receiver.is_none(), + "someone else's transfer has no channel of ours" + ); + + // Polling again must not double it up. + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); +} + +#[test] +fn a_transfer_the_session_forgets_leaves_every_window() { + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "gone.mp3")], + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); + + // The session no longer has it — cancelled from the other window. + tui.client = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert!( + tui.state.downloads.is_empty(), + "a queue is shared in both directions" + ); +} + +#[test] +fn a_window_shows_searches_run_in_another() { + let mut tui = + with_session(shared(vec![search_of("their query", 7, 9_999)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches.len(), 1); + assert_eq!(tui.state.searches[0].query, "their query"); + + // And it is not added a second time on the next frame. + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn a_search_another_window_is_still_running_shows_as_active() { + // It had said "Done" the moment it appeared, because an imported + // search carried no notion of still collecting — so the other window + // looked finished while results were still arriving. + let mut tui = with_session(shared(vec![search_of("still going", 3, 0)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches[0].status, SearchStatus::Active); + assert_eq!( + tui.state.searches[0].known_files, 3, + "and its count comes across before the results do" + ); +} + +#[test] +fn a_search_that_has_run_its_window_shows_as_done() { + let mut tui = with_session(shared(vec![search_of("finished", 12, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); +} + +#[test] +fn removing_a_search_removes_it_from_the_session() { + // Removing it only from this window would last until the next sync, + // which would put it straight back — and in a shared session it would + // come back for every other window too. + let mut tui = with_session(shared(vec![search_of("unwanted", 1, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + tui.remove_search_at_index(0); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty(), "and it stays gone"); +} + +#[test] +fn clearing_every_search_clears_them_in_the_session_too() { + let mut tui = with_session(shared(vec![ + search_of("one", 1, 9_999), + search_of("two", 2, 9_999), + ])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 2); + + tui.clear_all_searches(); + tui.update_search_results(); + + assert!( + tui.state.searches.is_empty(), + "clearing has to reach the session, or every row comes back" + ); +} + +#[test] +fn a_search_dismissed_in_another_window_leaves_this_one() { + let session = shared(vec![search_of("theirs", 4, 9_999)]); + let mut tui = with_session(session); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + // The other window dismissed it. + tui.client.forget_search("theirs"); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty()); +} + +#[test] +fn a_tick_asks_the_session_for_its_searches_once() { + // Against a daemon every call is a round trip, and the daemon answers + // it by walking its whole search cache — twice per frame was half the + // cost of attaching at all. + let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); + let mut tui = attach(session.clone()); + + tui.update_search_results(); + + assert_eq!( + session + .all_searches_calls + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "one sync means one listing, not one per use of it" + ); +} + +#[test] +fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { + // The result set only accumulates, so an unchanged count means a + // fetch would hand back what this window already holds — against a + // daemon that is the entire result set over the socket, every frame, + // forever. + let (session, tui) = window_that_pulled_two_files(); + + assert_eq!(tui.state.searches[0].results.len(), 2); + assert_eq!( + session + .result_fetches + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "the set crosses once; after that the unchanged count spares it" + ); +} + +#[test] +fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { + let (session, mut tui) = window_that_pulled_two_files(); + + // A late responder lands in the session. + *session.searches.lock().expect("not poisoned") = + vec![search_of("aphex twin", 3, 9_999)]; + *session.results.lock().expect("not poisoned") = + vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 3, + "a changed count must bring the new results across" + ); +} + +#[test] +fn a_local_window_fetches_results_without_counting_on_the_session() { + // Locally nothing maintains known_files, so the count gate must stay + // out of the way: a search whose count still reads zero has to get + // its results anyway. + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + shared: false, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + tui.state.searches.push(crate::models::SearchEntry { + query: "mine".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 2, + "a zero count matching zero held rows must not read as \ + nothing-to-fetch on a local session" + ); +} + +#[test] +fn a_window_with_its_own_session_is_left_alone() { + // Nothing to share, so nothing is synced — and in particular the + // completed transfers restored from disk, which the session has never + // heard of, are not mistaken for cancelled and deleted. + let mut tui = with_session(TalkativeSession { + downloads: Vec::new(), + shared: false, + ..TalkativeSession::default() + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "from-disk.mp3"), + receiver: None, + }); + + tui.update_downloads(); + + assert_eq!( + tui.state.downloads.len(), + 1, + "a local window's history must survive the tick" + ); +} + +#[test] +fn a_local_session_starts_from_its_own_snapshot_instead() { + // Locally the session knows no history and the state file is the whole + // record, so nothing is invented. + assert!(tui(Vec::new()).state.messages.is_empty()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs new file mode 100644 index 0000000..795267b --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -0,0 +1,201 @@ +//! The settings popup: the account it names, the folders it applies, +//! and the password and logout it can reach. + +use super::*; + +/// Attached, both folders in the settings form name paths on the +/// *daemon's* machine: they go over the socket exactly as typed, and +/// nothing is created or validated against this machine's filesystem. +#[test] +fn applying_settings_attached_pushes_both_folders_to_the_daemon() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let mut tui = MainTui::new( + session.clone(), + "/daemon/old".to_string(), + Duration::from_secs(1), + None, + Some(config_path.clone()), + ); + + let daemon_only = scratch.path().join("only-on-the-daemon"); + let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&daemon_dir); + settings.share_dirs = vec!["/not-here-either".to_string()]; + } + tui.apply_settings(); + + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(daemon_dir.clone()), + "the folder is pushed to the daemon" + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec!["/not-here-either".to_string()]), + "shares go over as typed — the daemon's filesystem judges them" + ); + assert!( + !daemon_only.exists(), + "no directory is created on this machine for a daemon's path" + ); + assert_eq!(tui.download_dir, daemon_dir); + + let saved = crate::persist::config::FileConfig::load(&config_path) + .expect("config readable"); + assert_eq!( + saved.download_dir.as_deref(), + Some(daemon_dir.as_str()), + "the change still lands in config.toml for the next restart" + ); +} + +/// Locally the same form works on this machine: the folder is created +/// here and share paths that do not exist here are dropped before the +/// session hears about them. +#[test] +fn applying_settings_locally_stays_on_this_machine() { + let session = Arc::new(TalkativeSession { + shared: false, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let share = scratch.path().join("music"); + std::fs::create_dir_all(&share).expect("share dir"); + let mut tui = MainTui::new( + session.clone(), + "/tmp".to_string(), + Duration::from_secs(1), + None, + Some(config_path), + ); + + let downloads = scratch.path().join("downloads"); + let downloads_dir = downloads.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&downloads_dir); + settings.share_dirs = vec![ + share.to_str().expect("utf-8").to_string(), + "/definitely-not-a-real-share".to_string(), + ]; + } + tui.apply_settings(); + + // The seam is told in both modes; a local session accepts and takes + // the directory per download instead of storing it. + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(downloads_dir.clone()) + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec![share.to_str().expect("utf-8").to_string()]), + "paths missing on this machine are dropped before applying" + ); + assert!(downloads.is_dir(), "the folder is created here"); + assert_eq!(tui.download_dir, downloads_dir); +} + +/// The account block answers "who is this session, and what is it +/// offering" without a trip to another screen. +#[test] +fn the_settings_popup_names_the_account_and_its_shares() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + + let screen = screen_sized(&mut tui, 100, 30); + assert!(screen.contains("tester"), "{screen}"); + assert!(screen.contains("12 files in 3 folders"), "{screen}"); +} + +/// Typed twice the same way, the new password reaches the session — and +/// attached to a daemon it is the daemon that stores it. +#[test] +fn a_password_typed_twice_reaches_the_session() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); // the change-password row is first + for _ in 0..2 { + for c in "hunter2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!( + *session.password_set.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!(settings.status.as_deref(), Some("Password changed")); +} + +/// A mistyped repeat is caught here rather than on the server, where the +/// account would be left with a password nobody knows. +#[test] +fn a_password_typed_two_different_ways_is_not_sent() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); + for typed in ["hunter2", "hunter3"] { + for c in typed.chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!(*session.password_set.lock().expect("not poisoned"), None); +} + +/// Logging out ends the window, but as a log-out: the caller brings the +/// login screen back rather than returning to the shell. +#[test] +fn logging_out_asks_first_and_then_closes_the_window() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); // change password -> log out + press(&mut tui, KeyCode::Enter); + assert!(tui.state.exit.is_none(), "the confirm comes first"); + + press(&mut tui, KeyCode::Char('y')); + assert_eq!(tui.state.exit, Some(TuiExit::Logout)); +} + +/// Attached, the login is the daemon's: there is no logout row to land +/// on, so the row below the password is the download folder. +#[test] +fn an_attached_window_offers_no_logout() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Enter); + + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!( + settings.mode, + crate::models::SettingsMode::EditingDownloadDir + ); + assert!(tui.state.exit.is_none()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/transfers.rs b/soulseek-rs/src/ui/main_tui/tests/transfers.rs new file mode 100644 index 0000000..6957d3c --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/transfers.rs @@ -0,0 +1,239 @@ +//! What the transfer lists do when a key lands on a row. + +use super::*; + +#[test] +fn x_cancels_the_highlighted_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "song.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![("bob".to_string(), "song.mp3".to_string())] + ); + assert!( + session + .upload_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the download row cancels no upload" + ); +} + +#[test] +fn shift_c_clears_the_whole_transfer_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "waiting.mp3"), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "carol", + "live.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("dave", "done.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('C')); + + assert!(tui.state.downloads.is_empty(), "every row goes"); + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ], + "live downloads are cancelled; the finished one is not" + ); + assert_eq!( + *session.download_removed.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ("dave".to_string(), "done.mp3".to_string()), + ], + "the session forgets every download, not just this window's rows" + ); + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())], + "a streaming upload is cancelled too" + ); +} + +#[test] +fn d_clears_a_cancelled_row() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "gone.mp3", + soulseek_rs::DownloadStatus::Cancelled, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes"); +} + +#[test] +fn x_on_a_finished_download_asks_nothing() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "a finished download is not cancelled" + ); +} + +#[test] +fn x_on_an_upload_row_still_cancels_the_upload() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(1)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())] + ); + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the upload row cancels no download" + ); +} + +#[test] +fn b_browses_the_user_of_the_highlighted_download() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert!(tui.state.show_browse, "the browse popup opens"); + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("bob"), + "the download's user is the one browsed" + ); + assert!( + !tui.state.command_bar_active, + "b on a transfer goes straight to the user, not the prompt" + ); +} + +#[test] +fn b_on_an_upload_row_browses_that_user() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("alice") + ); +} + +#[test] +fn d_clears_a_finished_row_but_not_a_running_one() { + // Finished transfers are what a list fills up with after a restart, so + // `d` has to be able to dismiss one — restored or not. A running + // transfer is not ours to discard, and stays put. + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes too"); + assert_eq!(tui.state.downloads_table_state.selected(), None); + + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "still.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 2, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + + tui.handle_key_event(key('d')); + + assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); +}