diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 094557c..1066de5 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -382,216 +382,6 @@ impl ClientContext { self.downloads.remove_queued_by_file(username, filename) } } -#[test] -fn test_client_context_downloads() { - let mut context = ClientContext::new(); - let token = 123; - let new_token = 1234; - let download = Download { - username: "test".to_string(), - filename: "test.txt".to_string(), - token, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::Queued, - sender: mpsc::channel().0, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - context.add_download(download); - 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(); - let download = Download { - username: "peer".to_string(), - filename: "song.mp3".to_string(), - token: 123, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - sender: download_sender, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - - client.context.write().unwrap().add_download(download); - - 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 { - username: "peer".to_string(), - filename: "f.mp3".to_string(), - token: 7, - size: 10, - download_directory: "d".to_string(), - status: DownloadStatus::Queued, - sender, - queue_position: None, - metadata: DownloadMetadata::default(), - }); - - 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") - .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").is_none()); - let _ = std::fs::remove_dir_all(dir); -} - -#[test] -fn test_client_removes_only_queued_downloads() { - let client = Client::new("test-user", "test-password"); - let queued_download = Download { - username: "peer".to_string(), - filename: "queued.mp3".to_string(), - token: 123, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::Queued, - sender: mpsc::channel().0, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - let active_download = Download { - username: "peer".to_string(), - filename: "active.mp3".to_string(), - token: 456, - size: 100, - download_directory: "test".to_string(), - status: DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - sender: mpsc::channel().0, - queue_position: None, - metadata: DownloadMetadata::default(), - }; - - { - let mut context = client.context.write().unwrap(); - context.add_download(queued_download); - context.add_download(active_download); - } - - 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()); -} impl ClientContext { #[must_use] @@ -996,128 +786,4 @@ mod upload_queue; mod uploads; #[cfg(test)] -mod tests { - use super::{ClientContext, upload_speed}; - 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}" - ); - } -} +mod tests; diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs new file mode 100644 index 0000000..a6eb2f4 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests.rs @@ -0,0 +1,334 @@ +use super::*; + +#[test] +fn test_client_context_downloads() { + let mut context = ClientContext::new(); + let token = 123; + let new_token = 1234; + let download = Download { + username: "test".to_string(), + filename: "test.txt".to_string(), + token, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + context.add_download(download); + 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(); + let download = Download { + username: "peer".to_string(), + filename: "song.mp3".to_string(), + token: 123, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + sender: download_sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + + client.context.write().unwrap().add_download(download); + + 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 { + username: "peer".to_string(), + filename: "f.mp3".to_string(), + token: 7, + size: 10, + download_directory: "d".to_string(), + status: DownloadStatus::Queued, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }); + + 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") + .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").is_none()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + let queued_download = Download { + username: "peer".to_string(), + filename: "queued.mp3".to_string(), + token: 123, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + let active_download = Download { + username: "peer".to_string(), + filename: "active.mp3".to_string(), + token: 456, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + sender: mpsc::channel().0, + queue_position: None, + metadata: DownloadMetadata::default(), + }; + + { + let mut context = client.context.write().unwrap(); + context.add_download(queued_download); + context.add_download(active_download); + } + + 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}" + ); +}