The seek bar was a setInterval counter reconciled only on track change: measured 3 s behind during steady playback and 30 s behind after four keyboard seeks, because the seek shortcut never told it. And `loadCurrentTrack`/`playCurrentTrack` logged, returned false and emitted nothing, so double-clicking a moved file did nothing, twice, forever — while auto-advance onto a bad file stopped playback dead. - A 1 Hz position ticker while playing, plus an immediate report on load, play, pause, seek and natural finish. The payload carries a `trackChangeId` (the store is a singleton, so a bar mounting later must not adopt a report about the previous track) and a `seq` (the same second reported twice still has to reset interpolation). - `PlaybackFailed` from both failure paths, and `playCurrentOrSkip` steps over tracks that will not load — bounded by the queue length, so a disconnected drive stops after one pass instead of spinning through a RepeatAll wrap. `PlayIndex` still reverts: the user picked that track. - `SeekFailed` is emitted when the seek itself fails, not only when nothing is loaded, and is followed by a position report so the optimistic move is taken back by the mechanism that fixed the drift. - A queue that simply ran out no longer unloads the player, so the finished track stays on the bar at 0:00.
86 lines
2.1 KiB
Go
86 lines
2.1 KiB
Go
package player
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"yellowjacket/backend/events"
|
|
)
|
|
|
|
// recordedPlayer is a player with an event sink installed and no audio
|
|
// device: enough to assert on what the frontend would receive from the
|
|
// paths that do not touch the speaker.
|
|
func recordedPlayer(t *testing.T) (*Player, *events.Recorder) {
|
|
t.Helper()
|
|
|
|
p := NewPlayer(slog.Default(), nil)
|
|
rec := events.NewRecorder()
|
|
p.SetContext(events.WithSink(t.Context(), rec))
|
|
|
|
return p, rec
|
|
}
|
|
|
|
func TestSeek_WithNoTrackEmitsSeekFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p, rec := recordedPlayer(t)
|
|
|
|
if err := p.Seek(5); err == nil {
|
|
t.Fatal("Seek with no track loaded returned nil error")
|
|
}
|
|
|
|
// C2: the frontend has made an optimistic move it now has to take
|
|
// back, and this is the only thing that tells it so.
|
|
if _, ok := rec.Last(events.SeekFailed); !ok {
|
|
t.Errorf("no SeekFailed emitted; got %v", rec.Names())
|
|
}
|
|
}
|
|
|
|
func TestPositionTicker_SilentWhileNotPlaying(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, rec := recordedPlayer(t)
|
|
|
|
// The ticker is running (SetContext started it) but nothing is
|
|
// playing, so a paused app must not push a position a second
|
|
// forever.
|
|
time.Sleep(positionTickInterval * 2)
|
|
|
|
if got := rec.Count(events.PlaybackPositionChanged); got != 0 {
|
|
t.Errorf("position emitted %d times while stopped, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestEmitPosition_CarriesLengthAndSequence(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
p := NewPlayer(slog.Default(), nil)
|
|
rec := events.NewRecorder()
|
|
p.ctx = events.WithSink(context.Background(), rec)
|
|
|
|
p.mu.Lock()
|
|
p.emitPositionLocked()
|
|
p.emitPositionLocked()
|
|
p.mu.Unlock()
|
|
|
|
ticks := rec.Named(events.PlaybackPositionChanged)
|
|
if len(ticks) != 2 {
|
|
t.Fatalf("emitted %d positions, want 2", len(ticks))
|
|
}
|
|
|
|
first, ok := ticks[0].Payload().(PositionInfo)
|
|
if !ok {
|
|
t.Fatalf("payload is %T, want player.PositionInfo", ticks[0].Payload())
|
|
}
|
|
|
|
second, _ := ticks[1].Payload().(PositionInfo)
|
|
|
|
// The sequence is what lets the seek bar reset its interpolation
|
|
// on a tick that reports the same second twice.
|
|
if second.Seq <= first.Seq {
|
|
t.Errorf("seq did not advance: %d then %d", first.Seq, second.Seq)
|
|
}
|
|
}
|