Phases 2 and 3 of plan 009, plus the parts of phase 1 that could not
land before them. Nothing in the tree imports wails/v2 any more; all
three lint and test configurations are green and `go build .` produces
a running binary.
The point of the migration is one file. backend/events/emit.go probed
ctx.Value("events") — a v2-*private* context key — to decide whether
emitting was safe, because runtime.EventsEmit called log.Fatalf on a
context without the runtime and took the process down with it. v3's
emit takes no context, so that is now application.Get() == nil. D1
held: events.Emit keeps its ctx as the WithSink test seam, and all 45
call sites and 7 test files are untouched.
The bootstrap splits into application.New + Window.NewWithOptions +
Run. Ten bound services implement ServiceStartup instead of being
handed a context by hand from OnStartup, which also stops ten
SetContext methods being exported as bindings. jobs.Registry and
explore.SearchIndex keep theirs — neither is bound, so converting them
would be churn for no binding removed.
Four things differed from the plan and are written up in it: GPU policy
moved to the per-window LinuxWindow options rather than surviving on
LinuxOptions; there is no OnStartup/OnDomReady option, so app-level
wiring hangs off ApplicationStarted; application.NewService is generic,
so FEBindings []any could not survive (the binding generator is a
static analyser and would have seen nothing); and the quit veto had to
be restructured, because v3's dialog answers on a callback rather than
returning the button, so ShouldQuit vetoes, asks, and quits again from
the callback.
Window state saving moves to a WindowClosing hook — the size has to be
read while the window still exists, and v3's OnShutdown has neither
context nor window. backend/logging is deleted rather than ported:
v3 takes a *slog.Logger directly, so the v2 logger.Logger adapter had
no caller left.
Phase 1's tail rides along, now that it can: the Makefile's wails
invocations, all 50 webkit2_41 sites, lefthook, both packaging recipes
and ci.yml's apt lists. v3 builds against GTK4 + WebKitGTK 6.0, which
Arch and ubuntu:24.04 both ship, so the tag is a deletion rather than
a translation.
Phase 4 is next and the branch is not usable until it lands: the app
builds, but frontend/wailsjs/ is v2's tree and nothing regenerates it,
so the frontend cannot reach the backend yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
286 lines
7.2 KiB
Go
286 lines
7.2 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])
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|