package queue import ( "context" "log/slog" "testing" "time" "github.com/wailsapp/wails/v3/pkg/application" "yellowjacket/backend/database" "yellowjacket/backend/events" ) // waitFor is how long a test waits for an event emitted from a // background goroutine (SetQueue resolves large queues in phases). const waitFor = 5 * time.Second // setupRecordedQueue is setupTestQueue with an event sink installed, so // what the frontend would receive is assertable. // // Before events.Emit existed this was impossible: SetContext with a // context.Background() made every emit call log.Fatalf inside Wails, // and SetContext(nil) made the queue skip emitting entirely — so the // payloads below have never been covered. func setupRecordedQueue(t *testing.T) (*Queue, *database.DB, *events.Recorder) { t.Helper() db := database.NewTestDB(t) q := NewQueue(slog.Default(), db) q.SetPlayer(&mockTrackLoader{}) rec := events.NewRecorder() _ = q.ServiceStartup(events.WithSink(context.Background(), rec), application.ServiceOptions{}) return q, db, rec } // stateOf returns the State payload of the most recent QueueChanged. func stateOf(t *testing.T, rec *events.Recorder) State { t.Helper() ev, ok := rec.Last(events.QueueChanged) if !ok { t.Fatalf("no QueueChanged emitted; got %v", rec.Names()) } state, ok := ev.Payload().(State) if !ok { t.Fatalf("QueueChanged payload is %T, want queue.State", ev.Payload()) } return state } // modifiedOf returns the TracksModified payload of the most recent // QueueTracksModified. func modifiedOf(t *testing.T, rec *events.Recorder) TracksModified { t.Helper() ev, ok := rec.Last(events.QueueTracksModified) if !ok { t.Fatalf("no QueueTracksModified emitted; got %v", rec.Names()) } mod, ok := ev.Payload().(TracksModified) if !ok { t.Fatalf( "QueueTracksModified payload is %T, want queue.TracksModified", ev.Payload(), ) } return mod } func TestEmit_SetQueuePushesFullState(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 2, false, Source{}) if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) } state := stateOf(t, rec) if len(state.Tracks) != 5 { t.Errorf("emitted %d tracks, want 5", len(state.Tracks)) } if state.CurrentIndex != 2 { t.Errorf("emitted currentIndex %d, want 2", state.CurrentIndex) } } // TestEmit_ClearSendsEmptyNotNilTrackList pins a frontend contract that // only exists in the emitted payload: the queue's own tracks field is // nil after Clear, and the store does `state.tracks.length` on receipt. func TestEmit_ClearSendsEmptyNotNilTrackList(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 3) q.SetQueue(paths, 0, false, Source{}) rec.Reset() q.Clear() state := stateOf(t, rec) if state.Tracks == nil { t.Error("emitted tracks is nil; the frontend reads .length on it") } if len(state.Tracks) != 0 { t.Errorf("emitted %d tracks after Clear, want 0", len(state.Tracks)) } if state.CurrentIndex != -1 { t.Errorf("emitted currentIndex %d after Clear, want -1", state.CurrentIndex) } } func TestEmit_CycleRepeatWalksAllModes(t *testing.T) { t.Parallel() q, _, rec := setupRecordedQueue(t) want := []RepeatMode{RepeatAll, RepeatOne, RepeatOff} for i, wantMode := range want { q.CycleRepeat() modeEvents := rec.Named(events.QueueModeChanged) if len(modeEvents) != i+1 { t.Fatalf("after %d cycles: %d QueueModeChanged events, want %d", i+1, len(modeEvents), i+1) } mode, ok := modeEvents[i].Payload().(ModeChanged) if !ok { t.Fatalf("payload is %T, want queue.ModeChanged", modeEvents[i].Payload()) } if mode.RepeatMode != wantMode { t.Errorf("cycle %d emitted %v, want %v", i+1, mode.RepeatMode, wantMode) } if mode.ShuffleMode { t.Errorf("cycle %d emitted shuffleMode true; only repeat changed", i+1) } } } func TestEmit_ToggleShuffleReportsBothModes(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) q.SetQueue(seedAudioFiles(t, db, 5), 0, false, Source{}) rec.Reset() q.ToggleShuffle() ev, ok := rec.Last(events.QueueModeChanged) if !ok { t.Fatalf("no QueueModeChanged; got %v", rec.Names()) } mode, ok := ev.Payload().(ModeChanged) if !ok { t.Fatalf("payload is %T, want queue.ModeChanged", ev.Payload()) } if !mode.ShuffleMode { t.Error("emitted shuffleMode false after ToggleShuffle") } // The mode event carries both modes, so a frontend that renders the // two toggles from one event cannot desync them. if mode.RepeatMode != RepeatOff { t.Errorf("emitted repeatMode %v, want RepeatOff", mode.RepeatMode) } } // TestEmit_AddTrackSendsDeltaNotSnapshot pins the distinction the whole // TracksModified type exists for: appending must not re-push the queue. func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 4) q.SetQueue(paths[:3], 0, false, Source{}) if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) } rec.Reset() q.AddTrack(paths[3]) if got := rec.Count(events.QueueChanged); got != 0 { t.Errorf("AddTrack emitted %d QueueChanged; want a delta only", got) } mod := modifiedOf(t, rec) if mod.Action != "add" { t.Errorf("action = %q, want \"add\"", mod.Action) } if mod.Index != 3 { t.Errorf("index = %d, want 3 (appended at the end)", mod.Index) } if len(mod.Tracks) != 1 || mod.Tracks[0].FilePath != paths[3] { t.Errorf("tracks = %v, want just %s", mod.Tracks, paths[3]) } } // The append clears the source, and the delta is the only event those // paths emit — so if it does not carry the source, the frontend keeps // the label it was last given and goes on offering a link back to an // album the queue no longer holds until something forces a full state. func TestEmit_AppendDeltaCarriesClearedSource(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 4) q.SetQueue( paths[:3], 0, false, Source{Type: "album", ID: 1, Label: "Abbey Road"}, ) if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) } rec.Reset() q.AddTrack(paths[3]) if got := modifiedOf(t, rec).Source; got != (Source{}) { t.Errorf("delta source = %+v, want zero value", got) } } // And a delta that did not clear it still reports the source it has, // or the frontend would drop a perfectly good label on every removal. func TestEmit_NonAppendDeltaCarriesSource(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 4) album := Source{Type: "album", ID: 1, Label: "Abbey Road"} q.SetQueue(paths, 0, false, album) if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) } rec.Reset() q.RemoveTrack(3) if got := modifiedOf(t, rec).Source; got != album { t.Errorf("delta source = %+v, want %+v", got, album) } } func TestEmit_RemoveTracksReportsPositions(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 5) q.SetQueue(paths, 0, false, Source{}) if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) } rec.Reset() q.RemoveTracks([]int{3, 1}) mod := modifiedOf(t, rec) if mod.Action != "remove" { t.Errorf("action = %q, want \"remove\"", mod.Action) } if len(mod.Positions) != 2 { t.Fatalf("positions = %v, want two entries", mod.Positions) } } // TestEmit_NextPushesIndexOnly covers auto-advance, which is what the // player calls at the end of a track: the frontend must be able to move // the now-playing highlight without re-rendering the queue. func TestEmit_NextPushesIndexOnly(t *testing.T) { t.Parallel() q, db, rec := setupRecordedQueue(t) paths := seedAudioFiles(t, db, 3) q.SetQueue(paths, 0, false, Source{}) if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok { t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names()) } rec.Reset() q.Next() q.Next() idxEvents := rec.Named(events.QueueIndexChanged) if len(idxEvents) != 2 { t.Fatalf("got %d QueueIndexChanged, want 2 (%v)", len(idxEvents), rec.Names()) } for i, ev := range idxEvents { idx, ok := ev.Payload().(IndexChanged) if !ok { t.Fatalf("payload is %T, want queue.IndexChanged", ev.Payload()) } if idx.CurrentIndex != i+1 { t.Errorf("advance %d emitted index %d, want %d", i+1, idx.CurrentIndex, i+1) } } if got := rec.Count(events.QueueChanged); got != 0 { t.Errorf("advancing emitted %d QueueChanged; want index deltas only", got) } }