Files
yellowjacket/backend/queue/playhistory_test.go
T
logan 1d335c5180 perf(queue): stop a finished track refetching the whole library
`recordPlay` emitted `TrackMetadataChanged`, which the frontend
correctly reads as "tags were rewritten" and answers by discarding
every cached collection: measured at 8 binding calls, 71.18 MB across
the IPC and a 765 ms longest task per two track changes at 50 000
tracks — once per song, while clearing the user's selection.

It now emits `TrackPlayCountChanged` with everything needed to patch
the one track in place, read back with `UPDATE ... RETURNING` so the
count cannot drift from the stored one. Measured after: 0 calls, 0 MB,
0 ms.
2026-08-12 01:17:54 -04:00

105 lines
2.8 KiB
Go

package queue
import (
"testing"
"yellowjacket/backend/events"
)
// A finished track used to emit TrackMetadataChanged — the event that
// means "the tags on disk were rewritten" — which the frontend answers
// by discarding its entire library cache and refetching tracks, albums,
// artists and genres. Measured on a 50 000-track library that is
// ~37 MB across the IPC and ~0.8 s of blocked main thread per song, and
// it cleared the user's track selection every time (audit perf.C1/C2).
//
// So the assertion that matters is not only that the new event fires:
// it is that the old one *stops*. A change that added
// TrackPlayCountChanged and left TrackMetadataChanged in place would
// look right in the payload and fix nothing.
func TestRecordPlay_EmitsPlayCountNotMetadataChanged(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 2)
q.SetQueue(paths, 0, false)
rec.Reset()
q.recordPlay(1)
if n := rec.Count(events.TrackMetadataChanged); n != 0 {
t.Errorf(
"recording a play emitted TrackMetadataChanged %d time(s); "+
"that event invalidates the whole library cache",
n,
)
}
ev, ok := rec.Last(events.TrackPlayCountChanged)
if !ok {
t.Fatalf(
"no TrackPlayCountChanged emitted; got %v", rec.Names(),
)
}
payload, ok := ev.Payload().(map[string]any)
if !ok {
t.Fatalf("payload is %T, want map[string]any", ev.Payload())
}
// The point of the payload is that it is enough to patch one track
// in place, so a consumer never needs to refetch anything. A
// missing field here means a consumer has to.
for _, key := range []string{
"audioFileId", "filePath", "playCount", "lastPlayed",
} {
if _, ok := payload[key]; !ok {
t.Errorf("payload is missing %q: %v", key, payload)
}
}
if got := payload["filePath"]; got != paths[0] {
t.Errorf("filePath: got %v, want %v", got, paths[0])
}
if got, want := payload["playCount"], int64(1); got != want {
t.Errorf("playCount: got %v (%T), want %v", got, got, want)
}
}
// The count comes from the database rather than from a counter the
// event handler keeps, so two plays report 1 then 2 — and a frontend
// that renders the payload cannot drift from the stored value.
func TestRecordPlay_ReportsTheStoredCount(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
rec.Reset()
q.recordPlay(1)
q.recordPlay(1)
got := make([]any, 0, 2)
for _, ev := range rec.Named(events.TrackPlayCountChanged) {
payload, ok := ev.Payload().(map[string]any)
if !ok {
t.Fatalf("payload is %T, want map[string]any", ev.Payload())
}
got = append(got, payload["playCount"])
}
if len(got) != 2 {
t.Fatalf("got %d events, want 2", len(got))
}
if got[0] != int64(1) || got[1] != int64(2) {
t.Errorf("play counts: got %v, want [1 2]", got)
}
}