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
216 lines
4.9 KiB
Go
216 lines
4.9 KiB
Go
package queue
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/events"
|
|
)
|
|
|
|
var errFileMissing = errors.New("no such file or directory")
|
|
|
|
// failingLoader is a TrackLoader that refuses to load a named set of
|
|
// paths — a moved file, in other words, which is the whole of
|
|
// errors.C1.
|
|
type failingLoader struct {
|
|
mockTrackLoader
|
|
fails map[string]bool
|
|
unloaded int
|
|
}
|
|
|
|
func (f *failingLoader) LoadFile(filePath string) error {
|
|
if f.fails[filePath] {
|
|
return errFileMissing
|
|
}
|
|
|
|
return f.mockTrackLoader.LoadFile(filePath)
|
|
}
|
|
|
|
func (f *failingLoader) UnloadTrack() { f.unloaded++ }
|
|
|
|
// setupFailingQueue is setupRecordedQueue with a loader that fails on
|
|
// the given paths.
|
|
func setupFailingQueue(
|
|
t *testing.T,
|
|
) (*Queue, *database.DB, *events.Recorder, *failingLoader) {
|
|
t.Helper()
|
|
|
|
db := database.NewTestDB(t)
|
|
q := NewQueue(slog.Default(), db)
|
|
loader := &failingLoader{fails: map[string]bool{}}
|
|
q.SetPlayer(loader)
|
|
|
|
rec := events.NewRecorder()
|
|
_ = q.ServiceStartup(events.WithSink(context.Background(), rec), application.ServiceOptions{})
|
|
|
|
return q, db, rec, loader
|
|
}
|
|
|
|
// failureOf returns the payload of the most recent PlaybackFailed.
|
|
func failureOf(t *testing.T, rec *events.Recorder) PlaybackFailure {
|
|
t.Helper()
|
|
|
|
ev, ok := rec.Last(events.PlaybackFailed)
|
|
if !ok {
|
|
t.Fatalf("no PlaybackFailed emitted; got %v", rec.Names())
|
|
}
|
|
|
|
failure, ok := ev.Payload().(PlaybackFailure)
|
|
if !ok {
|
|
t.Fatalf(
|
|
"PlaybackFailed payload is %T, want queue.PlaybackFailure",
|
|
ev.Payload(),
|
|
)
|
|
}
|
|
|
|
return failure
|
|
}
|
|
|
|
func TestPlaybackFailed_EmittedForAMissingFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, db, rec, loader := setupFailingQueue(t)
|
|
paths := seedAudioFiles(t, db, 3)
|
|
loader.fails[paths[1]] = true
|
|
|
|
q.SetQueue(paths, 0, false, Source{})
|
|
q.PlayIndex(1)
|
|
|
|
failure := failureOf(t, rec)
|
|
if failure.FilePath != paths[1] {
|
|
t.Errorf("filePath: got %q, want %q", failure.FilePath, paths[1])
|
|
}
|
|
|
|
if failure.Reason == "" {
|
|
t.Error("reason is empty; the frontend has nothing to log")
|
|
}
|
|
|
|
if failure.Title == "" {
|
|
t.Error("title is empty; a message cannot name the track")
|
|
}
|
|
}
|
|
|
|
func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, db, rec, loader := setupFailingQueue(t)
|
|
paths := seedAudioFiles(t, db, 3)
|
|
loader.fails[paths[1]] = true
|
|
|
|
q.SetQueue(paths, 0, false, Source{})
|
|
q.Play()
|
|
|
|
// The first track finished: auto-advance lands on the missing
|
|
// file and must step over it rather than stopping dead.
|
|
q.OnPlaybackFinished()
|
|
|
|
if got := q.GetState().CurrentIndex; got != 2 {
|
|
t.Errorf("currentIndex after skipping: got %d, want 2", got)
|
|
}
|
|
|
|
if _, ok := rec.Last(events.PlaybackFailed); !ok {
|
|
t.Errorf("skipped silently; events were %v", rec.Names())
|
|
}
|
|
}
|
|
|
|
func TestPlaybackFailed_NextSkipsPastIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, db, _, loader := setupFailingQueue(t)
|
|
paths := seedAudioFiles(t, db, 4)
|
|
loader.fails[paths[1]] = true
|
|
loader.fails[paths[2]] = true
|
|
|
|
q.SetQueue(paths, 0, false, Source{})
|
|
q.Next()
|
|
|
|
if got := q.GetState().CurrentIndex; got != 3 {
|
|
t.Errorf(
|
|
"currentIndex after two unplayable tracks: got %d, want 3",
|
|
got,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestPlaybackFailed_WholeQueueUnplayableStopsOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, db, rec, loader := setupFailingQueue(t)
|
|
paths := seedAudioFiles(t, db, 3)
|
|
|
|
for _, p := range paths {
|
|
loader.fails[p] = true
|
|
}
|
|
|
|
q.SetQueue(paths, 0, false, Source{})
|
|
q.repeatMode = RepeatAll
|
|
|
|
rec.Reset()
|
|
q.Next()
|
|
|
|
// Every file is gone (a disconnected drive). One pass, then
|
|
// stop — not an endless wrap around a RepeatAll queue.
|
|
if got := q.GetState().CurrentIndex; got != -1 {
|
|
t.Errorf("currentIndex: got %d, want -1 (exhausted)", got)
|
|
}
|
|
|
|
if got := rec.Count(events.PlaybackFailed); got != len(paths) {
|
|
t.Errorf(
|
|
"PlaybackFailed count: got %d, want %d (one pass)",
|
|
got, len(paths),
|
|
)
|
|
}
|
|
|
|
if loader.unloaded != 0 {
|
|
t.Errorf(
|
|
"player unloaded %d times; the finished track should stay "+
|
|
"on the now-playing bar",
|
|
loader.unloaded,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, db, _, loader := setupFailingQueue(t)
|
|
paths := seedAudioFiles(t, db, 1)
|
|
|
|
q.SetQueue(paths, 0, false, Source{})
|
|
q.Play()
|
|
q.OnPlaybackFinished()
|
|
|
|
if q.GetState().CurrentIndex != -1 {
|
|
t.Errorf(
|
|
"currentIndex: got %d, want -1",
|
|
q.GetState().CurrentIndex,
|
|
)
|
|
}
|
|
|
|
// H-18: the bar used to blank while the queue panel still listed
|
|
// what had just played.
|
|
if loader.unloaded != 0 {
|
|
t.Errorf("player unloaded %d times, want 0", loader.unloaded)
|
|
}
|
|
}
|
|
|
|
func TestQueueExhausted_UnloadsWhenTheTrackIsRemoved(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
q, db, _, loader := setupFailingQueue(t)
|
|
paths := seedAudioFiles(t, db, 1)
|
|
|
|
q.SetQueue(paths, 0, false, Source{})
|
|
q.RemoveTrack(0)
|
|
|
|
// Nothing left to show, so the bar clears.
|
|
if loader.unloaded == 0 {
|
|
t.Error("player not unloaded after its track left the queue")
|
|
}
|
|
}
|