Five faults found while auditing the play/pause and position path for a desktop report of the pause icon showing over a seek bar that was not moving. They are one commit because they are one file's worth of tangled state, and two of them do not compile apart. The finished callback did not know which chain it came from. It is dispatched as a goroutine from the beep callback and then queues for p.mu, so a user pressing Next in the last second of a track had it wake up holding the lock for a player that had loaded something else -- and rewind it, stop it, and hand a stale finish to the queue's auto-advance. updateStreamers now stamps a chainID and the callback carries the one it was registered with. (#123) It also emitted PlaybackFinished and PlaybackStateChanged(stopped) *after* releasing p.mu, alone in this file, so a Play() taking the lock in that gap emitted `playing` first and the stale `stopped` landed last -- the button showing play over a track that was audibly running. Both emits are back under the lock. (#123) A source that failed mid-track was reported to the queue as a natural end, so a broken file auto-advanced in silence and was counted as played. The handler takes the reason now: the player cannot name the track, because the metadata is the queue's, so the queue emits PlaybackFailed and skips recording the play. (#123) p.format was assigned once, in the constructor, to the *speaker's* rate, and never again -- so it claimed 44.1 kHz for every file. The replay-after-finish path resamples from it, meaning a finished track played a second time was resampled from a rate the decoder never produced: audibly wrong speed and pitch, and the length and position fallbacks wrong with it. The fixtures are 22050 Hz, which is what lets a test see this at all. (#124) p.trackLengthMs was written only when the database had a row and cleared only by UnloadTrack, so a file with no row inherited the previous track's duration -- and every position report is scaled by it, so the bar reported one track's progress on another's scale. (#125) Queue.OnPlaybackFinished indexed q.tracks[currentIndex] having checked only that the queue was non-empty. currentIndex is -1 whenever the queue has been exhausted, and onQueueExhausted deliberately leaves the finished track loaded -- so playing it from there and letting it end panicked, on a goroutine with no caller to recover it. (#126) The position readers guarded the decoder with the speaker lock, which the read-ahead goroutine has no reason to hold and never takes -- so Position() raced readAhead's Stream() on every position emit, once a second for the whole of playback. srcMu is the lock that excludes that goroutine, and taking it naively deadlocks, because seekLocked already holds it and then emits the landing position from inside that region. seekSourceLocked is that region extracted, so the lock is released before anything is emitted. Found by the race detector, via the test added here for the chain guard: the existing suite never loads a file outside the integration guard, so make test was green over it. (#127) OnPlaybackFinished picks up //wails:ignore along with its error parameter: v3's generator segfaults on a bound method taking an error, and this was never IPC. That removes a binding the frontend could have called to force an auto-advance. Closes #123 Closes #124 Closes #125 Closes #126 Closes #127
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(nil)
|
|
|
|
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(nil)
|
|
|
|
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")
|
|
}
|
|
}
|