package queue import ( "fmt" "log/slog" "testing" "yellowjacket/backend/database" ) // mockTrackLoader satisfies the TrackLoader interface for tests. // All methods are no-ops. type mockTrackLoader struct { loadedFile string } func (m *mockTrackLoader) LoadFile(filePath string) error { m.loadedFile = filePath return nil } func (m *mockTrackLoader) Play() error { return nil } func (m *mockTrackLoader) IsPlaying() bool { return false } func (m *mockTrackLoader) UnloadTrack() {} func (m *mockTrackLoader) CurrentPositionSeconds() (int, error) { return 0, nil } // setupTestQueue creates an isolated Queue backed by an in-memory DB. func setupTestQueue(t *testing.T) (*Queue, *database.DB) { t.Helper() db := database.NewTestDB(t) q := NewQueue(slog.Default(), db) q.SetPlayer(&mockTrackLoader{}) return q, db } // seedAudioFiles inserts `count` audio_file rows (with FK chain) and // returns the file paths as a string slice. func seedAudioFiles(t *testing.T, db *database.DB, count int) []string { t.Helper() paths := make([]string, count) for i := range count { paths[i] = fmt.Sprintf("/test/track%d.mp3", i+1) // Some tests seed overlapping ranges to build a fallback set. if _, err := db.Queries.GetAudioFileByPath(db.Ctx, paths[i]); err == nil { continue } database.InsertTestTrack(t, db, database.TestTrack{ FilePath: paths[i], Title: fmt.Sprintf("Track %d", i+1), Artist: "Test Artist", LengthMs: 180000, }) } return paths } func TestSetQueue_PopulatesTracks(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) state := q.GetState() if got := len(state.Tracks); got != 5 { t.Errorf("track count: got %d, want 5", got) } if state.CurrentIndex != 0 { t.Errorf("currentIndex: got %d, want 0", state.CurrentIndex) } } func TestSetQueue_RecordsSource(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) source := Source{Type: "playlist", ID: 42, Label: "Road Trip"} q.SetQueue(paths, 0, false, source) if got := q.GetState().Source; got != source { t.Errorf("source: got %+v, want %+v", got, source) } } func TestSetQueue_ReplacesPriorSource(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "First"}) q.SetQueue(paths, 0, false, Source{Type: "genre", Label: "Jazz"}) want := Source{Type: "genre", Label: "Jazz"} if got := q.GetState().Source; got != want { t.Errorf("source: got %+v, want %+v", got, want) } } func TestClear_ResetsSource(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "Some Album"}) q.Clear() if got := q.GetState().Source; got != (Source{}) { t.Errorf("source after Clear: got %+v, want zero value", got) } } // A queue built from one album stops being that album the moment a // track from somewhere else joins it, so every path that adds one // drops the source. Before this, SetQueue was the only writer and // Clear the only clearer, so "Playing from Abbey Road" outlived every // append — and, being persisted, every restart too. func TestAppendPathsDropSource(t *testing.T) { t.Parallel() album := Source{Type: "album", ID: 1, Label: "Abbey Road"} tests := []struct { name string append func(q *Queue, paths []string) }{ { name: "AddTrack", append: func(q *Queue, paths []string) { q.AddTrack(paths[5]) }, }, { name: "AddTracks", append: func(q *Queue, paths []string) { q.AddTracks(paths[5:7]) }, }, { name: "InsertNext", append: func(q *Queue, paths []string) { q.InsertNext(paths[5]) }, }, { name: "InsertNextTracks", append: func(q *Queue, paths []string) { q.InsertNextTracks(paths[5:7]) }, }, { name: "InsertTracksAt", append: func(q *Queue, paths []string) { q.InsertTracksAt(paths[5:7], 1) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 8) q.SetQueue(paths[:5], 0, false, album) if got := q.GetState().Source; got != album { t.Fatalf("source before append: got %+v, want %+v", got, album) } tt.append(q, paths) if got := q.GetState().Source; got != (Source{}) { t.Errorf( "source after %s: got %+v, want zero value", tt.name, got, ) } }) } } // Removing and reordering deliberately do not drop it: a queue with a // track taken out of it is still that album, and the link still goes // somewhere true. func TestRemoveAndMoveKeepSource(t *testing.T) { t.Parallel() album := Source{Type: "album", ID: 1, Label: "Abbey Road"} t.Run("RemoveTrack", func(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, album) q.RemoveTrack(3) if got := q.GetState().Source; got != album { t.Errorf("source after RemoveTrack: got %+v, want %+v", got, album) } }) t.Run("MoveQueueTracks", func(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, album) q.MoveQueueTracks([]int{0}, 3) if got := q.GetState().Source; got != album { t.Errorf( "source after MoveQueueTracks: got %+v, want %+v", got, album, ) } }) } func TestSetQueue_WithStartIndex(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 2, false, Source{}) state := q.GetState() if state.CurrentIndex != 2 { t.Errorf("currentIndex: got %d, want 2", state.CurrentIndex) } } func TestSetQueue_WithShuffleStart(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) // Enable shuffle mode first. q.ToggleShuffle() q.SetQueue(paths, 0, true, Source{}) state := q.GetState() if !state.ShuffleMode { t.Error("shuffleMode: got false, want true") } q.mu.Lock() soLen := len(q.shuffleOrder) q.mu.Unlock() if soLen != 5 { t.Errorf("shuffleOrder length: got %d, want 5", soLen) } } func TestAddTrack_AppendsToQueue(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 4) q.SetQueue(paths[:3], 0, false, Source{}) q.AddTrack(paths[3]) state := q.GetState() if got := len(state.Tracks); got != 4 { t.Errorf("track count: got %d, want 4", got) } lastTrack := state.Tracks[len(state.Tracks)-1] if lastTrack.FilePath != paths[3] { t.Errorf("last track path: got %q, want %q", lastTrack.FilePath, paths[3]) } } func TestInsertTracksAt_BeforeCurrentIndex(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 7) q.SetQueue(paths[:5], 2, false, Source{}) // Insert 2 tracks at index 1 (before currentIndex=2). q.InsertTracksAt(paths[5:7], 1) state := q.GetState() // currentIndex should shift by 2 (the number of inserted tracks). if state.CurrentIndex != 4 { t.Errorf("currentIndex after insert before: got %d, want 4", state.CurrentIndex) } if got := len(state.Tracks); got != 7 { t.Errorf("track count: got %d, want 7", got) } } func TestInsertTracksAt_AfterCurrentIndex(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 7) q.SetQueue(paths[:5], 2, false, Source{}) // Insert 2 tracks at index 3 (after currentIndex=2). q.InsertTracksAt(paths[5:7], 3) state := q.GetState() // currentIndex should remain 2. if state.CurrentIndex != 2 { t.Errorf("currentIndex after insert after: got %d, want 2", state.CurrentIndex) } } func TestMoveQueueTracks_ForwardMove(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) // Move track at index 1 to index 3. q.MoveQueueTracks([]int{1}, 3) state := q.GetState() // After moving index 1 forward: the track originally at index 1 // should now be at index 2 (adjustedIdx = 3-1 = 2). if state.Tracks[2].FilePath != paths[1] { t.Errorf("moved track: got %q at index 2, want %q", state.Tracks[2].FilePath, paths[1]) } } func TestMoveQueueTracks_BackwardMove(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) // Move track at index 3 to index 1. q.MoveQueueTracks([]int{3}, 1) state := q.GetState() // Track originally at index 3 should now be at index 1. if state.Tracks[1].FilePath != paths[3] { t.Errorf("moved track: got %q at index 1, want %q", state.Tracks[1].FilePath, paths[3]) } } func TestMoveQueueTracks_MoveCurrentTrack(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 2, false, Source{}) // Move the current track (index 2) to index 4. q.MoveQueueTracks([]int{2}, 4) state := q.GetState() // The current track should follow to its new position. currentPath := state.Tracks[state.CurrentIndex].FilePath if currentPath != paths[2] { t.Errorf("current track after move: got %q, want %q", currentPath, paths[2]) } } func TestRemoveTrack_RemovesCorrectTrack(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) q.RemoveTrack(2) state := q.GetState() if got := len(state.Tracks); got != 4 { t.Errorf("track count: got %d, want 4", got) } // Verify the removed track (paths[2]) is not present. for _, track := range state.Tracks { if track.FilePath == paths[2] { t.Errorf("removed track %q still present in queue", paths[2]) } } } func TestRemoveTrack_RemoveCurrentTrack(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 2, false, Source{}) q.RemoveTrack(2) state := q.GetState() if got := len(state.Tracks); got != 4 { t.Errorf("track count: got %d, want 4", got) } // After removing currentIndex=2, index should be clamped to valid range. if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Tracks) { t.Errorf( "currentIndex out of range: got %d, track count %d", state.CurrentIndex, len(state.Tracks), ) } } func TestClear_EmptiesQueue(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) q.Clear() state := q.GetState() if got := len(state.Tracks); got != 0 { t.Errorf("track count after clear: got %d, want 0", got) } if state.CurrentIndex != -1 { t.Errorf("currentIndex after clear: got %d, want -1", state.CurrentIndex) } } func TestToggleShuffle_TogglesMode(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) // Toggle on. q.ToggleShuffle() state := q.GetState() if !state.ShuffleMode { t.Error("shuffleMode after first toggle: got false, want true") } q.mu.Lock() soLen := len(q.shuffleOrder) q.mu.Unlock() if soLen != 5 { t.Errorf("shuffleOrder length after toggle on: got %d, want 5", soLen) } // Toggle off. q.ToggleShuffle() state = q.GetState() if state.ShuffleMode { t.Error("shuffleMode after second toggle: got true, want false") } q.mu.Lock() soLen = len(q.shuffleOrder) q.mu.Unlock() if soLen != 0 { t.Errorf("shuffleOrder length after toggle off: got %d, want 0", soLen) } } func TestCycleRepeat_CyclesThroughModes(t *testing.T) { t.Parallel() q, db := setupTestQueue(t) _ = seedAudioFiles(t, db, 1) // Default is RepeatOff. state := q.GetState() if state.RepeatMode != RepeatOff { t.Errorf("initial repeatMode: got %q, want %q", state.RepeatMode, RepeatOff) } // off -> all q.CycleRepeat() state = q.GetState() if state.RepeatMode != RepeatAll { t.Errorf("after first cycle: got %q, want %q", state.RepeatMode, RepeatAll) } // all -> one q.CycleRepeat() state = q.GetState() if state.RepeatMode != RepeatOne { t.Errorf("after second cycle: got %q, want %q", state.RepeatMode, RepeatOne) } // one -> off q.CycleRepeat() state = q.GetState() if state.RepeatMode != RepeatOff { t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff) } }