Files
yellowjacket/backend/queue/emit_test.go
yonluandClaude Opus 5 bf4f352117 fix(queue): stop claiming a queue came from somewhere it no longer does
`q.source` was written by SetQueue and cleared in exactly one place,
Clear, so no append path touched it: adding a track to a queue built
from an album left the page still offering "Playing from <that album>",
and since the source is persisted alongside the queue state the wrong
label outlived the session that earned it.

Every add and insert path drops it now. Removing and reordering
deliberately do not — a queue with a track taken out of it is still
that album, and the link still goes somewhere true. Only the arrival of
a track from elsewhere makes the claim false.

The delta event carries the source for the same reason it carries the
current index: an append emits nothing else, so the frontend would keep
the label it was last given until something forced a full state.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:05:43 -04:00

336 lines
8.7 KiB
Go

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)
}
}