Files
Bose-SoundTouch/pkg/service/soundtouchweb/source_cache_test.go
T
Tobias GesellchenandClaude Opus 5 7bd2a3d9f5 perf(player): confirm a source with one light readback, not three heavy ones
Selecting a source cost up to ~21 speaker requests. Two multipliers, both
removed here.

A match on the first readback only marked the command provisional and left
the later deadlines running, so the happy path always spent all three. The
reason to keep watching is real: /select answers 200 even for a source the
speaker rejects seconds later, surfacing as a transition to an error source.
But the event stream already reports that transition as it happens, and the
effect watching nowPlayingUpdated already turns it into a failure. Polling
on top is re-asking a question we are subscribed to the answer of. The
remaining readbacks are now kept only when the readback itself reports no
live event stream, which is the case they are actually needed for.

Each readback also fetched the whole device, and HandleAPIDevice runs a full
UpdateDeviceStatus: six sequential speaker calls plus /getGroup on a
stereo-capable model, to answer one question, against a device the readback
may be checking on precisely because it is slow. GET
/devices/{id}/now-playing refreshes only /now_playing and returns the same
shape, under FieldNowPlaying's generation so it still orders against push
events and concurrent polls, and reporting to the health tracker like any
other HTTP round.

A confirmed selection on a speaker with a live event stream now costs one
speaker request instead of about twenty-one. A speaker whose events are not
arriving keeps the full three-readback window, at one request each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00

244 lines
9.0 KiB
Go

package soundtouchweb
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sync"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure: a poll round
// where /now_playing failed but /volume succeeded must merge the volume and
// leave now-playing authority untouched. A source selection waiting for
// confirmation reads NowPlayingRevision, so advancing it on a failed read
// would confirm a selection nothing actually verified.
func TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/volume" {
_, _ = w.Write([]byte(`<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`))
return
}
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer speaker.Close()
conn := webtypes.NewDeviceConnection(client.NewClient(&client.Config{Host: speaker.URL}), nil)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "SPOTIFY"}})
baseline := conn.Status()
NewWebApp().UpdateDeviceStatus("speaker", conn)
updated := conn.Status()
if updated.Revision <= baseline.Revision || updated.Volume == nil || updated.Volume.ActualVolume != 35 {
t.Fatalf("unrelated successful field was not merged: %+v", updated)
}
if updated.NowPlaying.Source != "SPOTIFY" || updated.NowPlayingRevision != baseline.NowPlayingRevision {
t.Fatalf("failed now-playing read advanced source authority: %+v", updated)
}
}
// TestUpdateDeviceStatusMarksSourcesStaleOnRepeatedFailedReads: unlike every
// other field, a failed /sources read is still recorded. The last known
// inventory stays visible but goes unusable once reads keep failing, because
// offering source buttons the speaker no longer confirms is worse than
// offering none.
func TestUpdateDeviceStatusMarksSourcesStaleOnRepeatedFailedReads(t *testing.T) {
sourcesOK := true
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sources" {
if !sourcesOK {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<sources><sourceItem source="AUX" sourceAccount="AUX1" status="READY" isLocal="true">Aux 1</sourceItem></sources>`))
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`))
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(client.NewClient(&client.Config{Host: speaker.URL}), nil)
app.UpdateDeviceStatus("speaker", conn)
fresh := conn.Status()
if fresh.SourcesStale || fresh.Sources == nil || len(fresh.Sources.SourceItem) != 1 {
t.Fatalf("successful source read was not merged as actionable: %+v", fresh)
}
// One failure is not enough: a single transient hiccup must not disable
// the whole source list.
sourcesOK = false
app.UpdateDeviceStatus("speaker", conn)
if single := conn.Status(); single.SourcesStale {
t.Fatalf("one failed source read marked the inventory stale: %+v", single)
}
app.UpdateDeviceStatus("speaker", conn)
stale := conn.Status()
if !stale.SourcesStale {
t.Fatalf("consecutive failed source reads did not mark the inventory stale: %+v", stale)
}
if stale.Sources != fresh.Sources {
t.Fatalf("failed source read discarded the last known inventory: %+v", stale)
}
sourcesOK = true
app.UpdateDeviceStatus("speaker", conn)
if recovered := conn.Status(); recovered.SourcesStale {
t.Fatalf("successful source read did not clear staleness: %+v", recovered)
}
}
// TestHandleAPIDevicePublishesCanonicalReadback: the player's bounded readback
// after a source selection reads GET /devices/{id}, so that response must
// carry the same revisions the connection now holds -- not a snapshot taken
// before its own refresh.
func TestHandleAPIDevicePublishesCanonicalReadback(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responses := map[string]string{
"/now_playing": `<nowPlaying source="AUX" sourceAccount="AUX1"><track>Confirmed track</track><playStatus>PLAY_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets></presets>`,
"/sources": `<sources><sourceItem source="AUX" sourceAccount="AUX1" status="READY" isLocal="true">Aux 1</sourceItem></sources>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: speaker.URL}),
&models.DeviceInfo{Name: "Speaker"},
)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "STANDBY"}})
baselineRevision := conn.Status().NowPlayingRevision
conn.WebSocket = &client.WebSocketClient{}
app.AddDevice("speaker", conn)
req := httptest.NewRequest(http.MethodGet, "/api/control/devices/speaker", nil)
req = withChiParams(req, map[string]string{"id": "speaker"})
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET device status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Success bool `json:"success"`
Data struct {
Status webtypes.DeviceStatus `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode device readback: %v", err)
}
canonical := conn.Status()
if !response.Success || canonical.NowPlaying.Source != "AUX" || canonical.NowPlaying.Track != "Confirmed track" {
t.Fatalf("canonical readback not merged: response=%+v status=%+v", response, canonical)
}
if response.Data.Status.NowPlaying.Source != canonical.NowPlaying.Source ||
response.Data.Status.Revision != canonical.Revision ||
response.Data.Status.NowPlayingRevision != canonical.NowPlayingRevision ||
canonical.NowPlayingRevision <= baselineRevision {
t.Fatalf("response did not publish canonical status: response=%+v status=%+v", response.Data.Status, canonical)
}
}
// TestHandleDeviceNowPlayingPollsOnlyNowPlaying is the point of the endpoint:
// the source-selection readback needs one question answered, and going
// through the full device fetch would poll every field to answer it.
func TestHandleDeviceNowPlayingPollsOnlyNowPlaying(t *testing.T) {
var mu sync.Mutex
var paths []string
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
paths = append(paths, r.URL.Path)
mu.Unlock()
if r.URL.Path != "/now_playing" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<nowPlaying source="AUX" sourceAccount="AUX1"><playStatus>PLAY_STATE</playStatus></nowPlaying>`))
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: speaker.URL}),
&models.DeviceInfo{Name: "Speaker"},
)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "STANDBY"}})
baseline := conn.Status().NowPlayingRevision
app.AddDevice("speaker", conn)
req := httptest.NewRequest(http.MethodGet, "/api/control/devices/speaker/now-playing", nil)
req = withChiParams(req, map[string]string{"id": "speaker"})
w := httptest.NewRecorder()
app.HandleDeviceNowPlaying(w, req)
if w.Code != http.StatusOK {
t.Fatalf("now-playing readback = %d: %s", w.Code, w.Body.String())
}
mu.Lock()
defer mu.Unlock()
if want := []string{"/now_playing"}; !reflect.DeepEqual(paths, want) {
t.Fatalf("speaker requests = %v, want only %v", paths, want)
}
var response struct {
Success bool `json:"success"`
Data struct {
Status webtypes.DeviceStatus `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode now-playing readback: %v", err)
}
canonical := conn.Status()
if !response.Success || canonical.NowPlaying.Source != "AUX" {
t.Fatalf("now-playing was not merged: response=%+v status=%+v", response, canonical)
}
// The readback confirms a source by comparing this revision, so it has to
// carry the same values the connection now holds.
if response.Data.Status.NowPlayingRevision != canonical.NowPlayingRevision ||
response.Data.Status.Revision != canonical.Revision ||
canonical.NowPlayingRevision <= baseline {
t.Fatalf("response did not publish canonical revisions: response=%+v status=%+v",
response.Data.Status, canonical)
}
}