Duncan Tourolle ada3ed64ab Workstream E (backend): wire tauri-specta — annotate commands, derive Type, generate-ready Builder
- Add #[specta::specta] to all 201 #[tauri::command] functions.
- Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/
  download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState,
  ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.).
- Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands!
  in lib.rs (exports bindings.ts in debug builds).

Two contract changes required by specta constraints (frontend migration follows):
- specta caps command arity at 10 args: download_item_and_start / download_item /
  download_video now take a single request struct (params bundled, body unchanged
  via destructuring).
- specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState
  switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these
  now serialize PascalCase to the frontend).

cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
2026-06-20 18:20:25 +02:00

975 lines
32 KiB
Rust

use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use super::media::{MediaItem, MediaSource, QueueContext};
/// Repeat mode for the queue
///
/// TRACES: UR-005 | DR-005
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RepeatMode {
#[default]
Off,
All,
One,
}
/// Queue manager for playlist functionality
///
/// TRACES: UR-005, UR-015 | DR-005, DR-020
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub struct QueueManager {
/// All items in the queue
items: Vec<MediaItem>,
/// Current item index
current_index: Option<usize>,
/// Whether shuffle is enabled
shuffle: bool,
/// Current repeat mode
repeat: RepeatMode,
/// Shuffled order of indices (used when shuffle is on)
shuffle_order: Vec<usize>,
/// History of played indices (for going back with shuffle)
history: Vec<usize>,
/// Context for the queue (album, playlist, or custom)
/// Used for remote playback transfer to maintain album/playlist context
#[serde(default)]
context: QueueContext,
}
impl Default for QueueManager {
fn default() -> Self {
Self::new()
}
}
impl QueueManager {
pub fn new() -> Self {
Self {
items: Vec::new(),
current_index: None,
shuffle: false,
repeat: RepeatMode::Off,
shuffle_order: Vec::new(),
history: Vec::new(),
context: QueueContext::Custom,
}
}
/// Get all items in the queue
pub fn items(&self) -> &[MediaItem] {
&self.items
}
/// Get the current item index
pub fn current_index(&self) -> Option<usize> {
self.current_index
}
/// Get the current item
pub fn current(&self) -> Option<&MediaItem> {
self.current_index.and_then(|i| self.items.get(i))
}
/// Check if shuffle is enabled
pub fn is_shuffle(&self) -> bool {
self.shuffle
}
/// Get the current repeat mode
pub fn repeat_mode(&self) -> RepeatMode {
self.repeat
}
/// Get the current queue context (album, playlist, or custom)
pub fn context(&self) -> &QueueContext {
&self.context
}
/// Set the queue context
pub fn set_context(&mut self, context: QueueContext) {
self.context = context;
}
/// Set the queue with new items (resets context to Custom)
pub fn set_queue(&mut self, items: Vec<MediaItem>, start_index: usize) {
self.set_queue_with_context(items, start_index, QueueContext::Custom);
}
/// Set the queue with new items and explicit context
pub fn set_queue_with_context(
&mut self,
items: Vec<MediaItem>,
start_index: usize,
context: QueueContext,
) {
let start_index = start_index.min(items.len().saturating_sub(1));
if self.shuffle && !items.is_empty() {
self.shuffle_order = self.generate_shuffle_order(items.len(), Some(start_index));
} else {
self.shuffle_order.clear();
}
self.items = items;
self.current_index = if self.items.is_empty() {
None
} else {
Some(start_index)
};
self.history.clear();
self.context = context;
}
/// Add items to the queue
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
if items.is_empty() {
return;
}
let insert_index = match position {
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
AddPosition::End => self.items.len(),
};
// Insert items
for (i, item) in items.into_iter().enumerate() {
self.items.insert(insert_index + i, item);
}
// Update current index if needed
if let Some(current) = self.current_index {
if insert_index <= current {
self.current_index = Some(current + 1);
}
}
// Regenerate shuffle order if shuffle is on
if self.shuffle {
self.shuffle_order = self.generate_shuffle_order(
self.items.len(),
self.current_index,
);
}
}
/// Remove an item from the queue
pub fn remove(&mut self, index: usize) -> Option<MediaItem> {
if index >= self.items.len() {
return None;
}
let removed = self.items.remove(index);
// Update current index
if let Some(current) = self.current_index {
if index < current {
self.current_index = Some(current - 1);
} else if index == current {
self.current_index = if self.items.is_empty() {
None
} else if index >= self.items.len() {
Some(self.items.len() - 1)
} else {
Some(index)
};
}
}
// Update shuffle order
if self.shuffle {
self.shuffle_order = self.shuffle_order
.iter()
.filter(|&&i| i != index)
.map(|&i| if i > index { i - 1 } else { i })
.collect();
}
Some(removed)
}
/// Move to the next item
pub fn next(&mut self) -> Option<&MediaItem> {
if self.items.is_empty() {
return None;
}
let current = self.current_index?;
let next_index = if self.repeat == RepeatMode::One {
// Repeat current track
current
} else if self.shuffle {
// Find current position in shuffle order and get next
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
if pos + 1 < self.shuffle_order.len() {
self.shuffle_order[pos + 1]
} else if self.repeat == RepeatMode::All {
// Wrap around to beginning of shuffle
self.shuffle_order[0]
} else {
log::debug!("[Queue] next() at end of shuffle order, no next track");
return None;
}
} else {
// Normal sequential order
if current + 1 < self.items.len() {
current + 1
} else if self.repeat == RepeatMode::All {
0
} else {
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
return None;
}
};
// Only add to history if we're actually changing position
if next_index != current {
self.history.push(current);
log::debug!("[Queue] next() moving: {} -> {}", current, next_index);
} else {
log::debug!("[Queue] next() repeat one, staying at index {}", current);
}
self.current_index = Some(next_index);
self.items.get(next_index)
}
/// Move to the previous item
pub fn previous(&mut self) -> Option<&MediaItem> {
if self.items.is_empty() {
return None;
}
let current = self.current_index?;
// If we have history, go back in history
// But validate it to prevent wraparound bugs
if let Some(prev) = self.history.pop() {
// Safety check: ensure the history entry is valid
if prev >= self.items.len() {
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
prev, self.items.len());
self.history.clear();
return None;
}
// In non-shuffle mode, previous track should be before current (or this is from a skip_to)
// This prevents going from first track to last track
if !self.shuffle && prev >= current {
log::warn!("[Queue] Suspicious history: going from index {} to {} (non-shuffle mode), clearing history",
current, prev);
self.history.clear();
return None;
}
log::debug!("[Queue] previous() using history: {} -> {}", current, prev);
self.current_index = Some(prev);
return self.items.get(prev);
}
log::debug!("[Queue] previous() no history, current={}", current);
let prev_index = if self.shuffle {
// In shuffle mode without history, go to previous in shuffle order
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
if pos > 0 {
self.shuffle_order[pos - 1]
} else {
log::debug!("[Queue] previous() at start of shuffle order, staying at current");
return None;
}
} else {
// Normal sequential order
if current > 0 {
current - 1
} else {
log::debug!("[Queue] previous() at index 0, staying at current");
return None;
}
};
log::debug!("[Queue] previous() moving: {} -> {}", current, prev_index);
self.current_index = Some(prev_index);
self.items.get(prev_index)
}
/// Skip to a specific index
pub fn skip_to(&mut self, index: usize) -> Option<&MediaItem> {
if index >= self.items.len() {
return None;
}
if let Some(current) = self.current_index {
self.history.push(current);
}
self.current_index = Some(index);
self.items.get(index)
}
/// Toggle shuffle mode
pub fn toggle_shuffle(&mut self) {
self.shuffle = !self.shuffle;
if self.shuffle && !self.items.is_empty() {
self.shuffle_order = self.generate_shuffle_order(
self.items.len(),
self.current_index,
);
} else {
self.shuffle_order.clear();
}
}
/// Cycle through repeat modes
pub fn cycle_repeat(&mut self) {
self.repeat = match self.repeat {
RepeatMode::Off => RepeatMode::All,
RepeatMode::All => RepeatMode::One,
RepeatMode::One => RepeatMode::Off,
};
}
/// Set repeat mode directly (for testing)
#[cfg(test)]
pub fn set_repeat(&mut self, mode: RepeatMode) {
self.repeat = mode;
}
/// Check if there's a next item available
pub fn has_next(&self) -> bool {
if self.items.is_empty() {
return false;
}
match self.current_index {
None => false,
Some(current) => {
if self.repeat == RepeatMode::All || self.repeat == RepeatMode::One {
true
} else if self.shuffle {
let pos = self.shuffle_order.iter().position(|&i| i == current);
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
} else {
current + 1 < self.items.len()
}
}
}
}
/// Check if there's a previous item available
pub fn has_previous(&self) -> bool {
!self.history.is_empty() || {
match self.current_index {
None => false,
Some(current) => {
if self.shuffle {
let pos = self.shuffle_order.iter().position(|&i| i == current);
pos.map(|p| p > 0).unwrap_or(false)
} else {
current > 0
}
}
}
}
}
/// Get the next N upcoming items (for preloading)
/// Returns items that will play after the current item, respecting shuffle order
pub fn get_upcoming(&self, count: usize) -> Vec<&MediaItem> {
if self.items.is_empty() || count == 0 {
return Vec::new();
}
let current = match self.current_index {
Some(idx) => idx,
None => return Vec::new(),
};
let mut upcoming = Vec::with_capacity(count);
if self.shuffle {
// Find current position in shuffle order
if let Some(pos) = self.shuffle_order.iter().position(|&i| i == current) {
for i in 1..=count {
let next_pos = pos + i;
if next_pos < self.shuffle_order.len() {
if let Some(item) = self.items.get(self.shuffle_order[next_pos]) {
upcoming.push(item);
}
} else if self.repeat == RepeatMode::All {
// Wrap around if repeat all is enabled
let wrapped_pos = (next_pos) % self.shuffle_order.len();
if let Some(item) = self.items.get(self.shuffle_order[wrapped_pos]) {
upcoming.push(item);
}
}
}
}
} else {
// Normal sequential order
for i in 1..=count {
let next_idx = current + i;
if next_idx < self.items.len() {
upcoming.push(&self.items[next_idx]);
} else if self.repeat == RepeatMode::All {
// Wrap around if repeat all is enabled
let wrapped_idx = next_idx % self.items.len();
upcoming.push(&self.items[wrapped_idx]);
}
}
}
upcoming
}
/// Move an item from one index to another
pub fn move_item(&mut self, from_index: usize, to_index: usize) -> bool {
if from_index >= self.items.len() || to_index >= self.items.len() || from_index == to_index
{
return false;
}
// Remove the item and insert at new position
let item = self.items.remove(from_index);
self.items.insert(to_index, item);
// Update current_index if affected
if let Some(current) = self.current_index {
if current == from_index {
// The moved item was the current one
self.current_index = Some(to_index);
} else if from_index < current && to_index >= current {
// Item moved from before current to after/at current
self.current_index = Some(current - 1);
} else if from_index > current && to_index <= current {
// Item moved from after current to before/at current
self.current_index = Some(current + 1);
}
}
// Update shuffle order if shuffle is on
if self.shuffle && !self.shuffle_order.is_empty() {
// Regenerate shuffle order to maintain consistency
self.shuffle_order =
self.generate_shuffle_order(self.items.len(), self.current_index);
}
true
}
/// Update the stream URL of the current item (for transcoded seeking)
/// Returns true if the update was successful
pub fn update_current_stream_url(&mut self, new_url: String) -> bool {
if let Some(current_index) = self.current_index {
if let Some(item) = self.items.get_mut(current_index) {
// Only update if it's a Remote source
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
item.source = MediaSource::Remote {
stream_url: new_url,
jellyfin_item_id: jellyfin_item_id.clone(),
};
return true;
}
}
}
false
}
/// Generate a shuffle order, optionally starting from a specific index
fn generate_shuffle_order(&self, length: usize, start_index: Option<usize>) -> Vec<usize> {
let mut indices: Vec<usize> = (0..length).collect();
let mut rng = rand::thread_rng();
indices.shuffle(&mut rng);
// Move start index to the front if specified
if let Some(start) = start_index {
if let Some(pos) = indices.iter().position(|&i| i == start) {
indices.remove(pos);
indices.insert(0, start);
}
}
indices
}
}
/// Position to add items to the queue
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddPosition {
/// Add immediately after current item
Next,
/// Add at the end of the queue
End,
}
// TRACES: UR-005, UR-015 | DR-005 | UT-003, UT-004, UT-005
#[cfg(test)]
mod tests {
use super::*;
use crate::player::media::{MediaSource, MediaType};
fn create_test_items(count: usize) -> Vec<MediaItem> {
(0..count)
.map(|i| MediaItem {
id: format!("item_{}", i),
title: format!("Track {}", i + 1),
name: Some(format!("Track {}", i + 1)),
artist: Some("Artist".to_string()),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: Some(vec!["Artist".to_string()]),
primary_image_tag: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::DirectUrl {
url: format!("http://example.com/track_{}.mp3", i),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
})
.collect()
}
/// Test setting up the queue with items
///
/// @req-test: UR-015 - View and manage audio queue (add tracks)
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
#[test]
fn test_set_queue() {
let mut queue = QueueManager::new();
let items = create_test_items(5);
queue.set_queue(items.clone(), 0);
assert_eq!(queue.items().len(), 5);
assert_eq!(queue.current_index(), Some(0));
assert_eq!(queue.current().unwrap().id, "item_0");
}
/// Test next track navigation
///
/// @req-test: UR-005 - Control media playback (skip to next track)
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
#[test]
fn test_next() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 0);
assert_eq!(queue.current().unwrap().id, "item_0");
queue.next();
assert_eq!(queue.current().unwrap().id, "item_1");
queue.next();
assert_eq!(queue.current().unwrap().id, "item_2");
// No next without repeat
assert!(queue.next().is_none());
}
/// Test repeat all mode wraps to beginning
///
/// @req-test: UR-005 - Control media playback (repeat all mode)
/// @req-test: DR-005 - Queue manager with repeat
#[test]
fn test_repeat_all() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(2), 0);
queue.set_repeat(RepeatMode::All);
queue.next(); // Move to item_1
let next = queue.next(); // Should wrap to item_0
assert!(next.is_some());
assert_eq!(queue.current().unwrap().id, "item_0");
}
/// Test previous track navigation
///
/// @req-test: UR-005 - Control media playback (previous track)
/// @req-test: DR-005 - Queue manager
#[test]
fn test_previous() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 2);
queue.previous();
assert_eq!(queue.current_index(), Some(1));
}
/// Test viewing upcoming tracks in queue
///
/// @req-test: UR-015 - View and manage audio queue
/// @req-test: DR-020 - Queue management UI (upcoming tracks)
#[test]
fn test_get_upcoming() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(5), 0);
// Get next 3 items
let upcoming = queue.get_upcoming(3);
assert_eq!(upcoming.len(), 3);
assert_eq!(upcoming[0].id, "item_1");
assert_eq!(upcoming[1].id, "item_2");
assert_eq!(upcoming[2].id, "item_3");
// Move to item 2 and get upcoming
queue.next();
queue.next();
let upcoming = queue.get_upcoming(3);
assert_eq!(upcoming.len(), 2); // Only 2 items remaining
assert_eq!(upcoming[0].id, "item_3");
assert_eq!(upcoming[1].id, "item_4");
}
/// Test upcoming tracks with repeat all mode
///
/// @req-test: UR-015 - View and manage audio queue
/// @req-test: DR-005 - Queue manager with repeat
#[test]
fn test_get_upcoming_with_repeat() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 1);
queue.set_repeat(RepeatMode::All);
// At item_1, get upcoming with repeat
let upcoming = queue.get_upcoming(4);
assert_eq!(upcoming.len(), 4);
assert_eq!(upcoming[0].id, "item_2"); // Next
assert_eq!(upcoming[1].id, "item_0"); // Wrapped
assert_eq!(upcoming[2].id, "item_1"); // Wrapped (current again)
assert_eq!(upcoming[3].id, "item_2"); // Wrapped
}
/// Test upcoming tracks on empty queue
///
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
#[test]
fn test_get_upcoming_empty() {
let queue = QueueManager::new();
let upcoming = queue.get_upcoming(3);
assert!(upcoming.is_empty());
}
/// Test next stops at end without repeat mode
///
/// @req-test: UR-005 - Control media playback (queue end behavior)
/// @req-test: DR-005 - Queue manager
#[test]
fn test_next_no_repeat_reaches_end() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 0);
queue.set_repeat(RepeatMode::Off);
// At item 0, should have next
assert!(queue.has_next());
assert_eq!(queue.current_index(), Some(0));
// Move to item 1
queue.next();
assert!(queue.has_next());
assert_eq!(queue.current_index(), Some(1));
// Move to item 2 (last)
queue.next();
assert!(!queue.has_next()); // No more items
assert_eq!(queue.current_index(), Some(2));
// Try to move past end
let result = queue.next();
assert!(result.is_none());
assert_eq!(queue.current_index(), Some(2)); // Should stay at last item
}
/// Test next wraps to beginning with repeat all
///
/// @req-test: UR-005 - Control media playback (repeat all wrapping)
/// @req-test: DR-005 - Queue manager with repeat
#[test]
fn test_next_repeat_all_wraps() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 0);
queue.set_repeat(RepeatMode::All);
// With repeat all, has_next should always be true
assert!(queue.has_next());
// Move through all items
queue.next(); // item_1
assert!(queue.has_next());
queue.next(); // item_2
assert!(queue.has_next());
// Wrap to beginning
let result = queue.next();
assert!(result.is_some());
assert_eq!(queue.current().unwrap().id, "item_0");
assert!(queue.has_next()); // Still has next (loops forever)
}
/// Test next repeats same track with repeat one mode
///
/// @req-test: UR-005 - Control media playback (repeat one mode)
/// @req-test: DR-005 - Queue manager with repeat
#[test]
fn test_next_repeat_one_stays() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 1);
queue.set_repeat(RepeatMode::One);
// Should always have next (repeats current)
assert!(queue.has_next());
assert_eq!(queue.current_index(), Some(1));
// Call next multiple times - should stay on same track
for _ in 0..5 {
let result = queue.next();
assert!(result.is_some());
assert_eq!(queue.current().unwrap().id, "item_1");
assert_eq!(queue.current_index(), Some(1));
assert!(queue.has_next());
}
}
/// Test shuffle mode follows randomized order
///
/// @req-test: UR-005 - Control media playback (shuffle mode)
/// @req-test: DR-005 - Queue manager with shuffle
#[test]
fn test_next_shuffle_follows_order() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(4), 0);
queue.toggle_shuffle(); // Enable shuffle
// Get the shuffle order for verification
let shuffle_order = queue.shuffle_order.clone();
assert_eq!(shuffle_order.len(), 4);
// Current should be first item in shuffle order
let first_shuffled_index = shuffle_order[0];
assert_eq!(queue.current_index(), Some(first_shuffled_index));
// Move through shuffle order
for i in 1..shuffle_order.len() {
assert!(queue.has_next());
let result = queue.next();
assert!(result.is_some());
let expected_index = shuffle_order[i];
assert_eq!(queue.current_index(), Some(expected_index));
}
// At end of shuffle without repeat
assert!(!queue.has_next());
let result = queue.next();
assert!(result.is_none());
}
/// Test shuffle with repeat all wraps shuffle order
///
/// @req-test: UR-005 - Control media playback (shuffle + repeat)
/// @req-test: DR-005 - Queue manager with shuffle and repeat
#[test]
fn test_next_shuffle_with_repeat_all() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 0);
queue.toggle_shuffle(); // Enable shuffle
queue.set_repeat(RepeatMode::All);
let shuffle_order = queue.shuffle_order.clone();
// Move through entire shuffle order
for _ in 1..shuffle_order.len() {
queue.next();
}
// At end, should wrap to beginning of shuffle order
assert!(queue.has_next());
let result = queue.next();
assert!(result.is_some());
assert_eq!(queue.current_index(), Some(shuffle_order[0]));
}
/// Test has_next logic accuracy across different scenarios
///
/// @req-test: DR-005 - Queue manager (has_next accuracy)
/// @req-test: UR-015 - View and manage audio queue
#[test]
fn test_has_next_accuracy() {
// Test 1: Empty queue
let queue = QueueManager::new();
assert!(!queue.has_next());
// Test 2: Last track with repeat off
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(2), 1); // Start at last item
queue.set_repeat(RepeatMode::Off);
assert!(!queue.has_next());
// Test 3: Last track with repeat all
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(2), 1); // Start at last item
queue.set_repeat(RepeatMode::All);
assert!(queue.has_next()); // Should wrap
// Test 4: Repeat one mode (always has next)
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(1), 0);
queue.set_repeat(RepeatMode::One);
assert!(queue.has_next()); // Repeats forever
// Test 5: Middle of queue with repeat off
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 1); // Middle item
queue.set_repeat(RepeatMode::Off);
assert!(queue.has_next()); // Has item_2 next
// Test 6: Shuffle at end without repeat
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 0);
queue.toggle_shuffle(); // Enable shuffle
queue.set_repeat(RepeatMode::Off);
// Move to last item in shuffle order
let shuffle_len = queue.shuffle_order.len();
for _ in 1..shuffle_len {
queue.next();
}
assert!(!queue.has_next());
}
/// Test has_next on empty queue edge case
///
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
#[test]
fn test_has_next_empty_queue() {
let queue = QueueManager::new();
assert!(!queue.has_next());
assert_eq!(queue.current_index(), None);
}
/// Test that selecting a specific track in an album starts at the correct index
///
/// Reproduces the bug where clicking songs 1-5 always played song 13
#[test]
fn test_play_specific_track_from_album() {
let mut queue = QueueManager::new();
let items = create_test_items(14); // 14-track album
// Simulate playing track 0 (first track)
queue.set_queue(items.clone(), 0);
assert_eq!(queue.current_index(), Some(0));
assert_eq!(queue.current().unwrap().id, "item_0");
// Simulate playing track 3 (fourth track)
queue.set_queue(items.clone(), 3);
assert_eq!(queue.current_index(), Some(3));
assert_eq!(queue.current().unwrap().id, "item_3");
// Simulate playing track 13 (last track)
queue.set_queue(items, 13);
assert_eq!(queue.current_index(), Some(13));
assert_eq!(queue.current().unwrap().id, "item_13");
}
/// Test that next() then previous() returns to the original track
#[test]
fn test_next_previous_roundtrip() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(5), 2); // Start at middle track
assert_eq!(queue.current_index(), Some(2));
// Go to next track
queue.next();
assert_eq!(queue.current_index(), Some(3));
// Go back - should return to track 2
queue.previous();
assert_eq!(queue.current_index(), Some(2));
assert_eq!(queue.current().unwrap().id, "item_2");
}
/// Test that previous() at the first track stays at first track
#[test]
fn test_previous_at_first_track_stays() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(5), 0); // Start at first track
assert_eq!(queue.current_index(), Some(0));
// Try to go to previous - should stay at 0
let result = queue.previous();
assert!(result.is_none());
assert_eq!(queue.current_index(), Some(0));
assert_eq!(queue.current().unwrap().id, "item_0");
}
/// Test that next() at last track (no repeat) stays at last track
#[test]
fn test_next_at_last_track_stays() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(5), 4); // Start at last track
assert_eq!(queue.current_index(), Some(4));
// Try to go to next - should return None and stay at 4
let result = queue.next();
assert!(result.is_none());
assert_eq!(queue.current_index(), Some(4));
assert_eq!(queue.current().unwrap().id, "item_4");
}
/// Test history validation prevents invalid wraparound
#[test]
fn test_history_validation_prevents_wraparound() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(5), 0);
// Manually corrupt history to simulate the bug
// (In the real bug, history would have last track's index)
queue.history.push(4);
// Try to go previous - should detect invalid history and clear it
let result = queue.previous();
assert!(result.is_none()); // Should not wrap to track 4
assert_eq!(queue.current_index(), Some(0)); // Should stay at track 0
assert!(queue.history.is_empty()); // History should be cleared
}
/// Test multiple next() calls build correct history
#[test]
fn test_multiple_next_builds_history() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(5), 0);
// Navigate: 0 -> 1 -> 2 -> 3
queue.next(); // Now at 1, history=[0]
queue.next(); // Now at 2, history=[0, 1]
queue.next(); // Now at 3, history=[0, 1, 2]
assert_eq!(queue.current_index(), Some(3));
// Go back through history: 3 -> 2 -> 1 -> 0
queue.previous();
assert_eq!(queue.current_index(), Some(2));
queue.previous();
assert_eq!(queue.current_index(), Some(1));
queue.previous();
assert_eq!(queue.current_index(), Some(0));
}
}