mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
statusUpdated (which drives IsConnected) ORed in
"stereoCapable && groupErr == nil" alongside the five substantive
status fetches. Since GetGroup is gated to stereo-capable models and
trivially succeeds even when a device is struggling (an empty
<group/> is a near-guaranteed reply, per Client.GetGroup's doc
comment), a round where NowPlaying/Volume/Presets/Sources/Bass all
fail but GetGroup alone succeeds would still report the device
connected -- masking a real status-refresh failure specifically on
ST10 hardware.
Removed the extra OR term entirely: IsConnected now depends only on
the five substantive fetches, matching the comment's own stated
intent ("mirrors prior behaviour"). GetGroup's own success/failure
still drives whether Group gets refreshed (unchanged, see
ApplyPolledGroup below), just no longer feeds the connectivity signal.
Added TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds,
verified to fail against the prior logic and pass with this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
251 lines
8.3 KiB
Go
251 lines
8.3 KiB
Go
package soundtouchweb
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/client"
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
|
)
|
|
|
|
func TestUpdateDeviceStatusRefreshesGroup(t *testing.T) {
|
|
server := newStatusTestServer(t, http.StatusOK, `<group id="pair-1">
|
|
<name>Living Room</name>
|
|
<masterDeviceId>master-1</masterDeviceId>
|
|
<roles>
|
|
<groupRole><deviceId>master-1</deviceId><role>LEFT</role></groupRole>
|
|
<groupRole><deviceId>member-1</deviceId><role>RIGHT</role></groupRole>
|
|
</roles>
|
|
<status>GROUP_OK</status>
|
|
</group>`)
|
|
defer server.Close()
|
|
|
|
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
|
|
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
|
|
|
status := conn.Status()
|
|
if status.Group == nil {
|
|
t.Fatal("Group was not populated by UpdateDeviceStatus")
|
|
}
|
|
|
|
if status.Group.ID != "pair-1" || status.Group.MasterDeviceID != "master-1" {
|
|
t.Errorf("Group = %+v, want refreshed stereo pair", status.Group)
|
|
}
|
|
|
|
if len(status.Group.Roles.Roles) != 2 {
|
|
t.Errorf("group roles = %d, want 2", len(status.Group.Roles.Roles))
|
|
}
|
|
|
|
if !status.IsConnected {
|
|
t.Error("successful status refresh should mark the device connected")
|
|
}
|
|
}
|
|
|
|
func TestUpdateDeviceStatusPreservesGroupOnError(t *testing.T) {
|
|
server := newStatusTestServer(t, http.StatusInternalServerError, "group unavailable")
|
|
defer server.Close()
|
|
|
|
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
|
|
existing := &models.Group{ID: "pair-old", Name: "Existing Pair"}
|
|
conn.SetStatus(&webtypes.DeviceStatus{Group: existing})
|
|
|
|
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
|
|
|
status := conn.Status()
|
|
if status.Group != existing {
|
|
t.Errorf("Group = %+v, want previous group preserved on refresh error", status.Group)
|
|
}
|
|
|
|
if !status.IsConnected {
|
|
t.Error("other successful status fetches should keep the device connected")
|
|
}
|
|
}
|
|
|
|
func TestUpdateDeviceStatusSkipsGroupForNonStereoModel(t *testing.T) {
|
|
var groupRequested atomic.Bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/getGroup" {
|
|
groupRequested.Store(true)
|
|
http.Error(w, "unsupported endpoint", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
responses := map[string]string{
|
|
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
|
|
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
|
|
"/presets": `<presets/>`,
|
|
"/sources": `<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 server.Close()
|
|
|
|
for _, model := range []string{"SoundTouch 20", "SoundTouch 30"} {
|
|
t.Run(model, func(t *testing.T) {
|
|
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: model})
|
|
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
|
|
|
if !conn.Status().IsConnected {
|
|
t.Fatal("successful ordinary status requests should mark a non-stereo model connected")
|
|
}
|
|
})
|
|
}
|
|
|
|
if groupRequested.Load() {
|
|
t.Fatal("UpdateDeviceStatus requested /getGroup for a non-stereo model")
|
|
}
|
|
}
|
|
|
|
// TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds covers a stereo-
|
|
// capable device where every substantive status fetch fails but /getGroup
|
|
// alone succeeds (a near-guaranteed reply -- even an empty <group/> is a
|
|
// success, see Client.GetGroup's doc comment). IsConnected must not be set
|
|
// from GetGroup's success alone, or a device with genuinely stale
|
|
// NowPlaying/Volume/Presets/Sources/Bass data would be reported connected.
|
|
func TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/getGroup" {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(`<group/>`))
|
|
|
|
return
|
|
}
|
|
|
|
http.Error(w, "device struggling", http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
|
|
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
|
|
|
if conn.Status().IsConnected {
|
|
t.Fatal("IsConnected must stay false when every substantive status fetch failed, even though GetGroup alone succeeded")
|
|
}
|
|
}
|
|
|
|
func TestApplyGroupUpdatedEventReplacesGroup(t *testing.T) {
|
|
conn := webtypes.NewDeviceConnection(nil, nil)
|
|
previousActivity := time.Unix(1, 0)
|
|
conn.SetStatus(&webtypes.DeviceStatus{
|
|
Group: &models.Group{ID: "pair-old"},
|
|
Volume: &models.Volume{ActualVolume: 25},
|
|
IsConnected: true,
|
|
LastActivity: previousActivity,
|
|
})
|
|
|
|
event := &models.GroupUpdatedEvent{
|
|
Group: models.Group{ID: "pair-new", Name: "Renamed Pair"},
|
|
}
|
|
applyGroupUpdatedEvent(conn, event)
|
|
|
|
status := conn.Status()
|
|
if status.Group != &event.Group || status.Group.ID != "pair-new" {
|
|
t.Errorf("Group = %+v, want event group", status.Group)
|
|
}
|
|
|
|
if status.Volume == nil || status.Volume.ActualVolume != 25 || !status.IsConnected {
|
|
t.Errorf("unrelated status fields were not preserved: %+v", status)
|
|
}
|
|
|
|
if !status.LastActivity.After(previousActivity) {
|
|
t.Errorf("LastActivity = %s, want after %s", status.LastActivity, previousActivity)
|
|
}
|
|
|
|
teardown := &models.GroupUpdatedEvent{Group: models.Group{}}
|
|
applyGroupUpdatedEvent(conn, teardown)
|
|
|
|
if conn.Status().Group != nil {
|
|
t.Errorf("teardown event did not clear the group: %+v", conn.Status().Group)
|
|
}
|
|
}
|
|
|
|
func TestPeriodicPlayerMessagesPreserveStatusUpdateStream(t *testing.T) {
|
|
app := NewWebApp()
|
|
group := testStereoGroup()
|
|
for _, entry := range []DeviceEntry{
|
|
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
|
|
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
|
projectionDevice("192.0.2.12", "standalone-id", "Kitchen", false, nil),
|
|
} {
|
|
app.AddDevice(entry.ID, entry.Device)
|
|
}
|
|
|
|
messages := app.periodicPlayerMessages()
|
|
if len(messages) != 3 {
|
|
t.Fatalf("periodic messages = %d, want one devices frame and two connected status updates: %+v", len(messages), messages)
|
|
}
|
|
|
|
if messages[0].Type != "devices" {
|
|
t.Fatalf("first periodic message type = %q, want devices", messages[0].Type)
|
|
}
|
|
devices, ok := messages[0].Data.(map[string]deviceView)
|
|
if !ok || len(devices) != 2 || devices["192.0.2.10"].StereoPair == nil {
|
|
t.Fatalf("periodic devices frame is not the logical projection: %#v", messages[0].Data)
|
|
}
|
|
|
|
statusUpdates := make(map[string]bool)
|
|
for _, message := range messages[1:] {
|
|
if message.Type != "status_update" {
|
|
t.Fatalf("periodic message type = %q, want status_update", message.Type)
|
|
}
|
|
statusUpdates[message.DeviceID] = true
|
|
}
|
|
if !statusUpdates["192.0.2.10"] || !statusUpdates["192.0.2.11"] || statusUpdates["192.0.2.12"] {
|
|
t.Fatalf("unexpected status_update device IDs: %+v", statusUpdates)
|
|
}
|
|
}
|
|
|
|
func newStatusTestServer(t *testing.T, groupStatus int, groupBody string) *httptest.Server {
|
|
t.Helper()
|
|
|
|
responses := map[string]string{
|
|
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
|
|
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
|
|
"/presets": `<presets/>`,
|
|
"/sources": `<sources/>`,
|
|
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
|
|
}
|
|
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("method for %s = %s, want GET", r.URL.Path, r.Method)
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
|
|
if r.URL.Path == "/getGroup" {
|
|
w.WriteHeader(groupStatus)
|
|
_, _ = w.Write([]byte(groupBody))
|
|
|
|
return
|
|
}
|
|
|
|
body, ok := responses[r.URL.Path]
|
|
if !ok {
|
|
t.Errorf("unexpected status endpoint %q", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
}
|