/* * This file is a part of RSPlayer * Licensed under the GPL-3.0 License * Copyright (C) 2026 Salmonik @ salmonik@tuta.io * https://codeberg.org/salmonik/RSPlayer */ use rsp::queue::{Queue, ShuffleMode, Track}; use std::path::PathBuf; fn track(path: &str, title: Option<&str>, artist: Option<&str>, album: Option<&str>) -> Track { Track { path: PathBuf::from(path), title: title.map(|s| s.to_string()), artist: artist.map(|s| s.to_string()), album: album.map(|s| s.to_string()), duration_secs: None, } } fn track_no_meta(path: &str) -> Track { Track { path: PathBuf::from(path), title: None, artist: None, album: None, duration_secs: None, } } fn add_track(q: &mut Queue, path: &str) { q.add_track(PathBuf::from(path)); } // ---------- Queue basics ---------- #[test] fn test_new_queue_is_empty() { let q = Queue::new(); assert!(q.is_empty()); assert_eq!(q.len(), 0); assert!(q.current().is_none()); assert_eq!(q.position(), 0); } #[test] fn test_add_track_and_current() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); assert!(!q.is_empty()); assert_eq!(q.len(), 1); let cur = q.current(); assert!(cur.is_some()); assert_eq!(cur.unwrap().path, PathBuf::from("/music/song1.mp3")); } #[test] fn test_current_on_empty_queue() { let q = Queue::new(); assert!(q.current().is_none()); } #[test] fn test_next_advances_position() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); assert_eq!(q.position(), 0); let next = q.next(); assert!(next.is_some()); assert_eq!(next.unwrap(), PathBuf::from("/music/song2.mp3")); assert_eq!(q.position(), 1); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3")); } #[test] fn test_next_at_end_returns_none() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); assert!(q.next().is_none()); assert_eq!(q.position(), 0); } #[test] fn test_next_from_last_track_returns_none() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); assert!(q.next().is_some()); assert!(q.next().is_none()); assert_eq!(q.position(), 1); } #[test] fn test_previous_goes_back() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); add_track(&mut q, "/music/song3.mp3"); q.next(); q.next(); assert_eq!(q.position(), 2); let prev = q.previous(); assert!(prev.is_some()); assert_eq!(prev.unwrap(), PathBuf::from("/music/song2.mp3")); assert_eq!(q.position(), 1); } #[test] fn test_previous_at_start_returns_none() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); assert!(q.previous().is_none()); assert_eq!(q.position(), 0); } #[test] fn test_previous_multiple_steps() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); add_track(&mut q, "/music/song3.mp3"); q.next(); q.next(); q.previous(); assert_eq!(q.position(), 1); q.previous(); assert_eq!(q.position(), 0); assert!(q.previous().is_none()); assert_eq!(q.position(), 0); } // ---------- Clear ---------- #[test] fn test_clear_queue() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); q.next(); q.clear(); assert!(q.is_empty()); assert_eq!(q.len(), 0); assert_eq!(q.position(), 0); assert!(q.current().is_none()); } #[test] fn test_clear_empty_queue() { let mut q = Queue::new(); q.clear(); assert!(q.is_empty()); } // ---------- Insert after current ---------- #[test] fn test_insert_after_current_empty_queue() { let mut q = Queue::new(); q.insert_after_current(PathBuf::from("/music/song1.mp3")); assert_eq!(q.len(), 1); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song1.mp3")); } #[test] fn test_insert_after_current_middle() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song3.mp3"); // Currently at position 0 (song1) q.insert_after_current(PathBuf::from("/music/song2.mp3")); assert_eq!(q.len(), 3); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song1.mp3")); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3")); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song3.mp3")); } #[test] fn test_insert_after_current_at_end() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); q.next(); q.insert_after_current(PathBuf::from("/music/song3.mp3")); assert_eq!(q.len(), 3); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song3.mp3")); } #[test] fn test_insert_after_current_multiple_ordering() { let mut q = Queue::new(); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song4.mp3"); // Queue: [song1, song4], pos=0 // Insert song2, then song3 after current q.insert_after_current(PathBuf::from("/music/song2.mp3")); q.insert_after_current(PathBuf::from("/music/song3.mp3")); // Expected: [song1, song3, song2, song4] // Each insert goes at pos+1, so song3 ends up right after song1, pushing song2 right assert_eq!(q.len(), 4); q.next(); assert_eq!( q.current().unwrap().path, PathBuf::from("/music/song3.mp3"), "last insert goes right after current" ); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3")); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song4.mp3")); } // ---------- Track display ---------- #[test] fn test_track_display_with_metadata() { let t = track( "/music/test.mp3", Some("Test Title"), Some("Test Artist"), Some("Test Album"), ); let display = t.display_string(0, 1); assert_eq!(display, "1. Test Artist - Test Title"); } #[test] fn test_track_display_artist_fallback_to_album() { let t = track( "/music/test.mp3", Some("Test Title"), None, Some("Test Album"), ); let display = t.display_string(0, 1); assert_eq!(display, "1. Test Album - Test Title"); } #[test] fn test_track_display_no_metadata() { let t = track_no_meta("/music/song.mp3"); let display = t.display_string(0, 1); assert_eq!(display, "1. song.mp3"); } #[test] fn test_track_display_index_offset() { let t = track( "/music/test.mp3", Some("Title"), Some("Artist"), Some("Album"), ); assert_eq!(t.display_string(4, 10), "05. Artist - Title"); } #[test] fn test_track_format_variables() { let t = track( "/music/test.mp3", Some("My Song"), Some("John Doe"), Some("Great Album"), ); assert_eq!(t.format("$ARTIST - $TRACK"), "John Doe - My Song"); assert_eq!(t.format("$TRACK ($ALBUM)"), "My Song (Great Album)"); assert_eq!(t.format("Now playing: $ARTIST"), "Now playing: John Doe"); } #[test] fn test_track_format_empty_variables() { let t = track_no_meta("/music/test.mp3"); assert_eq!(t.format("$ARTIST - $TRACK"), " - test.mp3"); } #[test] fn test_track_format_missing_variables() { let t = track("/music/test.mp3", Some("My Song"), Some("John Doe"), None); assert_eq!(t.format("Track: $TRACK"), "Track: My Song"); assert_eq!(t.format("Album: $ALBUM"), "Album: "); } // ---------- Shuffle ---------- #[test] fn test_shuffle_modes() { let mut q = Queue::new(); assert_eq!(*q.shuffle_mode(), ShuffleMode::None); q.set_shuffle(ShuffleMode::All); assert_eq!(*q.shuffle_mode(), ShuffleMode::All); q.set_shuffle(ShuffleMode::Album); assert_eq!(*q.shuffle_mode(), ShuffleMode::Album); q.set_shuffle(ShuffleMode::Albums); assert_eq!(*q.shuffle_mode(), ShuffleMode::Albums); q.set_shuffle(ShuffleMode::None); assert_eq!(*q.shuffle_mode(), ShuffleMode::None); } #[test] fn test_shuffle_all_navigates_in_shuffled_order() { let mut q = Queue::new(); for i in 0..10 { add_track(&mut q, &format!("/music/song{}.mp3", i)); } q.set_shuffle(ShuffleMode::All); // Navigate through all tracks, should visit all 10 (in shuffled order) let mut visited = std::collections::HashSet::new(); visited.insert(q.current().unwrap().path.clone()); while let Some(next_path) = q.next() { assert!( visited.insert(next_path), "duplicate track in shuffle navigation" ); } assert_eq!(visited.len(), 10, "should visit all 10 tracks"); } #[test] fn test_shuffle_all_current_stays_at_position_zero() { let mut q = Queue::new(); for i in 0..5 { add_track(&mut q, &format!("/music/song{}.mp3", i)); } q.set_shuffle(ShuffleMode::All); assert_eq!(q.position(), 0); let mut sorted = q.shuffled_indices().to_vec(); sorted.sort(); assert_eq!(sorted, vec![0, 1, 2, 3, 4]); } #[test] fn test_set_shuffle_regenerates_indices() { let mut q = Queue::new(); for i in 0..5 { add_track(&mut q, &format!("/music/song{}.mp3", i)); } q.set_shuffle(ShuffleMode::All); let indices1 = q.shuffled_indices().to_vec(); // Each set_shuffle should regenerate (small chance of same order) q.set_shuffle(ShuffleMode::All); let indices2 = q.shuffled_indices().to_vec(); // Check that all indices are still valid (0..5) let mut sorted1 = indices1.clone(); sorted1.sort(); let mut sorted2 = indices2.clone(); sorted2.sort(); assert_eq!(sorted1, vec![0, 1, 2, 3, 4]); assert_eq!(sorted2, vec![0, 1, 2, 3, 4]); } #[test] fn test_shuffle_none_linear_navigation() { let mut q = Queue::new(); for i in 0..5 { add_track(&mut q, &format!("/music/song{}.mp3", i)); } assert_eq!(*q.shuffle_mode(), ShuffleMode::None); assert_eq!(q.shuffled_indices(), &[0, 1, 2, 3, 4]); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song0.mp3")); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song1.mp3")); q.next(); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3")); } // ---------- Edge cases ---------- #[test] fn test_single_track_queue() { let mut q = Queue::new(); add_track(&mut q, "/music/only.mp3"); assert_eq!(q.len(), 1); assert!(q.current().is_some()); assert!(q.next().is_none()); assert_eq!(q.position(), 0); assert!(q.previous().is_none()); assert_eq!(q.position(), 0); } #[test] fn test_set_position() { let mut q = Queue::new(); add_track(&mut q, "/music/song0.mp3"); add_track(&mut q, "/music/song1.mp3"); add_track(&mut q, "/music/song2.mp3"); q.set_position(2); assert_eq!(q.position(), 2); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song2.mp3")); q.set_position(0); assert_eq!(q.position(), 0); assert_eq!(q.current().unwrap().path, PathBuf::from("/music/song0.mp3")); } #[test] fn test_set_position_beyond_end() { let mut q = Queue::new(); add_track(&mut q, "/music/song0.mp3"); add_track(&mut q, "/music/song1.mp3"); q.set_position(5); // current() should handle out-of-bounds position gracefully let cur = q.current(); assert!(cur.is_none()); assert!(q.next().is_none()); } #[test] fn test_len_and_is_empty() { let mut q = Queue::new(); assert_eq!(q.len(), 0); assert!(q.is_empty()); add_track(&mut q, "/music/a.mp3"); assert_eq!(q.len(), 1); assert!(!q.is_empty()); add_track(&mut q, "/music/b.mp3"); assert_eq!(q.len(), 2); q.clear(); assert_eq!(q.len(), 0); assert!(q.is_empty()); } #[test] fn test_track_at() { let mut q = Queue::new(); add_track(&mut q, "/music/song0.mp3"); add_track(&mut q, "/music/song1.mp3"); assert_eq!( q.track_at(0).unwrap().path, PathBuf::from("/music/song0.mp3") ); assert_eq!( q.track_at(1).unwrap().path, PathBuf::from("/music/song1.mp3") ); assert!(q.track_at(2).is_none()); } // ---------- Shuffle + add interaction ---------- #[test] fn test_add_track_updates_shuffle_indices() { let mut q = Queue::new(); q.set_shuffle(ShuffleMode::All); add_track(&mut q, "/music/a.mp3"); assert_eq!(q.shuffled_indices().len(), 1); add_track(&mut q, "/music/b.mp3"); assert_eq!(q.shuffled_indices().len(), 2); add_track(&mut q, "/music/c.mp3"); assert_eq!(q.shuffled_indices().len(), 3); } #[test] fn test_insert_after_current_updates_shuffle_indices() { let mut q = Queue::new(); q.set_shuffle(ShuffleMode::All); add_track(&mut q, "/music/a.mp3"); q.insert_after_current(PathBuf::from("/music/b.mp3")); assert_eq!(q.shuffled_indices().len(), 2); } #[test] fn test_clear_resets_shuffle_indices() { let mut q = Queue::new(); q.set_shuffle(ShuffleMode::All); add_track(&mut q, "/music/a.mp3"); add_track(&mut q, "/music/b.mp3"); q.clear(); assert!(q.shuffled_indices().is_empty()); } // ---------- Track struct ---------- #[test] fn test_track_fields() { let t = track( "/path/to/file.flac", Some("Title"), Some("Artist"), Some("Album"), ); assert_eq!(t.path, PathBuf::from("/path/to/file.flac")); assert_eq!(t.title.unwrap(), "Title"); assert_eq!(t.artist.unwrap(), "Artist"); assert_eq!(t.album.unwrap(), "Album"); } #[test] fn test_track_no_metadata_fields() { let t = track_no_meta("/path/to/file.flac"); assert_eq!(t.path, PathBuf::from("/path/to/file.flac")); assert!(t.title.is_none()); assert!(t.artist.is_none()); assert!(t.album.is_none()); } // ---------- Shuffle albums ---------- #[test] fn test_shuffle_albums_groups_tracks_by_album() { let mut q = Queue::new(); q.push_track(track( "/music/a1.mp3", Some("A1"), Some("Artist"), Some("Album A"), )); q.push_track(track( "/music/a2.mp3", Some("A2"), Some("Artist"), Some("Album A"), )); q.push_track(track( "/music/b1.mp3", Some("B1"), Some("Artist"), Some("Album B"), )); q.push_track(track( "/music/b2.mp3", Some("B2"), Some("Artist"), Some("Album B"), )); q.push_track(track( "/music/c1.mp3", Some("C1"), Some("Artist"), Some("Album C"), )); // Queue: Album A (a1, a2), Album B (b1, b2), Album C (c1) q.set_shuffle(ShuffleMode::Albums); q.shuffle_initial(); // full shuffle of everything (as happens at play start) let shuffled = q.shuffled_indices().to_vec(); assert_eq!(shuffled.len(), 5); // Tracks from the same album must appear as a contiguous block let shuffled_tracks: Vec<_> = shuffled.iter().map(|&i| q.track_at(i).unwrap()).collect(); // Find positions of all tracks let a_positions: Vec<_> = shuffled_tracks .iter() .enumerate() .filter(|(_, t)| t.album.as_deref() == Some("Album A")) .map(|(i, _)| i) .collect(); let b_positions: Vec<_> = shuffled_tracks .iter() .enumerate() .filter(|(_, t)| t.album.as_deref() == Some("Album B")) .map(|(i, _)| i) .collect(); assert_eq!(a_positions.len(), 2, "Album A should have 2 tracks"); assert_eq!(b_positions.len(), 2, "Album B should have 2 tracks"); assert_eq!( a_positions[1] - a_positions[0], 1, "Album A tracks should be contiguous" ); assert_eq!( b_positions[1] - b_positions[0], 1, "Album B tracks should be contiguous" ); } #[test] fn test_shuffle_albums_solo_track_forms_own_group() { let mut q = Queue::new(); q.push_track(track("/music/x.mp3", Some("X"), None, None)); q.push_track(track("/music/y.mp3", Some("Y"), None, Some("Album Y"))); q.push_track(track("/music/z.mp3", Some("Z"), None, Some("Album Y"))); q.set_shuffle(ShuffleMode::Albums); let shuffled = q.shuffled_indices().to_vec(); assert_eq!(shuffled.len(), 3); // Track without album should form its own group of size 1 // Tracks y and z share Album Y and should be adjacent let shuffled_tracks: Vec<_> = shuffled.iter().map(|&i| q.track_at(i).unwrap()).collect(); let y_pos = shuffled_tracks .iter() .position(|t| t.title.as_deref() == Some("Y")) .unwrap(); let z_pos = shuffled_tracks .iter() .position(|t| t.title.as_deref() == Some("Z")) .unwrap(); assert_eq!( (y_pos as isize - z_pos as isize).abs(), 1, "tracks from same album must be adjacent" ); } // ---------- Shuffle restore ---------- #[test] fn test_shuffle_all_then_none_keeps_played_tracks_in_place() { // User's scenario: // Queue after shuffle all: 01, 02, 03, 04(playing), 05, 06... // Skip to track 04, then shuffle none // Expected: played tracks (01,02,03) stay in place, current (04) stays, // remaining tracks from original order after position let mut q = Queue::new(); // Add tracks A, B, C, D, E, F (A is at index 0) add_track(&mut q, "/music/A.mp3"); add_track(&mut q, "/music/B.mp3"); add_track(&mut q, "/music/C.mp3"); add_track(&mut q, "/music/D.mp3"); add_track(&mut q, "/music/E.mp3"); add_track(&mut q, "/music/F.mp3"); // Shuffle all - should save original and shuffle q.set_shuffle(ShuffleMode::All); // Simulate playing: advance position a few times // After playing A, B, C, we're at position 2 (track C) // Let's say shuffled order puts D at position 2 q.next(); // position 1 (playing second track) q.next(); // position 2 (playing third track) q.next(); // position 3 (playing fourth track) let pos_before_restore = q.position(); // Now shuffle none - should restore q.set_shuffle(ShuffleMode::None); // After restore: // - position should be same as before // - tracks before current position should remain in same order as when playing // - tracks after current should be from original order that haven't been played let current = q.current().unwrap(); println!("Position after restore: {}", q.position()); println!("Current track: {:?}", current.path); // The position should stay the same assert_eq!(q.position(), pos_before_restore); // Tracks should be: [played tracks in played order...] [current] [unplayed from original] // Let's verify by checking that tracks before current include the ones that were played for i in 0..q.position() { println!("Track at {}: {:?}", i, q.track_at(i).unwrap().path); } } #[test] fn test_shuffle_all_then_none_exact_scenario() { // Exact scenario from user: // Original queue: 1, 2, 3, 4, 5, 6 (position 0 = track 1) // shuffle all -> 1, 2(playing), 4, 5, 6, 3 // play 2 more -> 1, 2, 4, 5(playing), 6, 3 // shuffle none -> 1, 2, 4, 5(playing), 3, 6 let mut q = Queue::new(); add_track(&mut q, "/music/1.mp3"); add_track(&mut q, "/music/2.mp3"); add_track(&mut q, "/music/3.mp3"); add_track(&mut q, "/music/4.mp3"); add_track(&mut q, "/music/5.mp3"); add_track(&mut q, "/music/6.mp3"); // Original order: [1, 2, 3, 4, 5, 6] // Position 0 = track 1 // Shuffle all q.set_shuffle(ShuffleMode::All); // Assume after shuffle and navigating to position 3 (playing track 5), // shuffled order is [1, 2, 4, 5, 6, 3] // We need to set this up manually since shuffle is random // Let's simulate by setting position to 3 (after playing 1, 2, 4, now playing 5) // Position 3 means we've played indices 0,1,2 in shuffled order = tracks 1,2,4 // Currently at position 3 = track 5 // First get the shuffled indices and manually set up a specific order let indices: Vec = q.shuffled_indices().to_vec(); println!("Shuffled indices: {:?}", indices); // Force position to 3 to simulate playing 3 tracks then current is track at pos 3 q.set_position(3); let current_before = q.current().unwrap().path.clone(); println!( "Current before restore: {:?}", current_before.file_name().unwrap() ); println!("Position: {}", q.position()); // Now shuffle none q.set_shuffle(ShuffleMode::None); println!("After restore:"); for i in 0..q.len() { println!( " {}: {:?}", i + 1, q.track_at(i).unwrap().path.file_name().unwrap() ); } // After restore: // - Position should still be 3 // - Tracks at positions 0,1,2,3 should be the played tracks in their order (1,2,4,5) // - Tracks 4,5 should be from original unplayed (3,6) assert_eq!(q.position(), 3, "position should stay same"); let current_after = q.current().unwrap().path.clone(); assert_eq!( current_before, current_after, "current track should stay same" ); } #[test] fn test_shuffle_restore_simulation() { // Simulate exact user scenario: // Queue: 1,2,3,4,5,6 (original) // shuffle all -> shuffled order e.g., [4,2,6,1,5,3] // Navigate 4 times (play 1,2,4,5 -> position 4, now playing 5) // Expected after shuffle none: 4,2,6,1,5,3 (played in place, unplayed from original) let mut q = Queue::new(); for i in 1..=6 { add_track(&mut q, &format!("/music/{}.mp3", i)); } // Shuffle all (saves original as [1,2,3,4,5,6]) q.set_shuffle(ShuffleMode::All); // Let's say shuffled order is [4, 2, 6, 1, 5, 3] // We can force this by inspecting the indices println!("Initial shuffled: {:?}", q.shuffled_indices()); // Navigate 4 times for _ in 0..4 { q.next(); } println!("After 4 next() calls:"); println!(" Position: {}", q.position()); println!( " Current: {:?}", q.current().unwrap().path.file_name().unwrap() ); println!(" Tracks:"); for i in 0..q.len() { println!( " {}: {:?}", i, q.track_at(i).unwrap().path.file_name().unwrap() ); } let current_path = q.current().unwrap().path.clone(); let pos_before = q.position(); // Shuffle none q.set_shuffle(ShuffleMode::None); println!("After shuffle none:"); println!(" Position: {}", q.position()); println!( " Current: {:?}", q.current().unwrap().path.file_name().unwrap() ); println!(" Tracks:"); for i in 0..q.len() { println!( " {}: {:?}", i, q.track_at(i).unwrap().path.file_name().unwrap() ); } // Assertions assert_eq!(q.position(), pos_before, "position should be preserved"); assert_eq!( q.current().unwrap().path, current_path, "current track should be preserved" ); // The key requirement: tracks before current position should remain in played order // and tracks after should be remaining from original in original order let pos = q.position(); for i in 0..pos { println!( "Played track {}: {:?}", i, q.track_at(i).unwrap().path.file_name().unwrap() ); } }