Files
yellowjacket/backend/player/emit_test.go
T
logan df11ef23f4 feat(player): report the real position, and skip an unplayable track
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.
2026-08-12 01:17:54 -04:00

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