Files
Bose-SoundTouch/pkg/models/websocket_test.go
T
Tobias GesellchenandClaude Opus 4.8 44d16e54da fix(client): log unhandled WebSocket event names instead of empty list
An <updates> frame whose only child is an element the WebSocketEvent
struct doesn't model (e.g. nowSelectionUpdated, sent by SoundTouch 10
firmware around a play action) produced no known event types, so
handleEvent logged "Received unknown event types: []" repeatedly. The
empty list carried no information and flooded soundtouch-web's logs and
the CLI events subscribe output we point people at for debugging.

Capture unmodeled <updates> children by name via an xml:",any" catch-all
on WebSocketEvent and log the actual element names ("[nowSelectionUpdated]"),
skipping frames that carry no child events entirely. A regression test
confirms a modeled event is not also captured as unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:00:58 +02:00

581 lines
16 KiB
Go

package models
import (
"testing"
"time"
)
func TestWebSocketEventType_String(t *testing.T) {
tests := []struct {
name string
event WebSocketEventType
expected string
}{
{"NowPlaying", EventTypeNowPlaying, "Now Playing Updated"},
{"VolumeUpdated", EventTypeVolumeUpdated, "Volume Updated"},
{"ConnectionState", EventTypeConnectionState, "Connection State Updated"},
{"PresetUpdated", EventTypePresetUpdated, "Preset Updated"},
{"ZoneUpdated", EventTypeZoneUpdated, "Zone Updated"},
{"GroupUpdated", EventTypeGroupUpdated, "Stereo Pair Updated"},
{"BassUpdated", EventTypeBassUpdated, "Bass Updated"},
{"ClockTimeUpdated", EventTypeClockTimeUpdated, "Clock Time Updated"},
{"ClockDisplayUpdated", EventTypeClockDisplayUpdated, "Clock Display Updated"},
{"NameUpdated", EventTypeNameUpdated, "Name Updated"},
{"ErrorUpdated", EventTypeErrorUpdated, "Error Updated"},
{"RecentsUpdated", EventTypeRecentsUpdated, "Recents Updated"},
{"LanguageUpdated", EventTypeLanguageUpdated, "Language Updated"},
{"Unknown", EventTypeUnknown, "Unknown Event"},
{"Invalid", WebSocketEventType("invalid"), "Unknown Event"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.event.String()
if result != tt.expected {
t.Errorf("WebSocketEventType.String() = %v, want %v", result, tt.expected)
}
})
}
}
func TestConnectionState_IsConnected(t *testing.T) {
tests := []struct {
name string
state string
expected bool
}{
{"Connected", "CONNECTED", true},
{"Disconnected", "DISCONNECTED", false},
{"Connecting", "CONNECTING", false},
{"Unknown", "UNKNOWN", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := &ConnectionState{State: tt.state}
result := cs.IsConnected()
if result != tt.expected {
t.Errorf("ConnectionState.IsConnected() = %v, want %v", result, tt.expected)
}
})
}
}
func TestConnectionState_GetSignalStrength(t *testing.T) {
cs := &ConnectionState{Signal: "EXCELLENT"}
result := cs.GetSignalStrength()
expected := "EXCELLENT"
if result != expected {
t.Errorf("ConnectionState.GetSignalStrength() = %v, want %v", result, expected)
}
}
func TestParseWebSocketEvent(t *testing.T) {
t.Run("ValidNowPlayingEvent", func(t *testing.T) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<nowPlayingUpdated deviceID="689E19B8BB8A">
<nowPlaying deviceID="689E19B8BB8A" source="SPOTIFY">
<track>Test Track</track>
<artist>Test Artist</artist>
<album>Test Album</album>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>
</nowPlayingUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent() failed: %v", err)
}
if event == nil {
t.Fatal("ParseWebSocketEvent() returned nil event")
}
if event.DeviceID != "689E19B8BB8A" {
t.Errorf("Expected DeviceID '689E19B8BB8A', got '%s'", event.DeviceID)
}
if !event.HasEventType(EventTypeNowPlaying) {
t.Error("Expected event to have EventTypeNowPlaying")
}
if event.NowPlayingUpdated == nil {
t.Error("Expected NowPlayingUpdated to be populated")
}
// Check timestamp was added
if event.Timestamp.IsZero() {
t.Error("Expected timestamp to be set")
}
})
t.Run("ValidVolumeEvent", func(t *testing.T) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<volumeUpdated deviceID="689E19B8BB8A">
<volume deviceID="689E19B8BB8A">
<targetvolume>25</targetvolume>
<actualvolume>25</actualvolume>
<muteenabled>false</muteenabled>
</volume>
</volumeUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent() failed: %v", err)
}
if event == nil {
t.Fatal("ParseWebSocketEvent() returned nil event")
}
if !event.HasEventType(EventTypeVolumeUpdated) {
t.Error("Expected event to have EventTypeVolumeUpdated")
}
if event.VolumeUpdated == nil {
t.Error("Expected VolumeUpdated to be populated")
}
})
t.Run("MultipleEvents", func(t *testing.T) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<volumeUpdated deviceID="689E19B8BB8A">
<volume deviceID="689E19B8BB8A">
<targetvolume>30</targetvolume>
<actualvolume>30</actualvolume>
<muteenabled>false</muteenabled>
</volume>
</volumeUpdated>
<bassUpdated deviceID="689E19B8BB8A">
<bass deviceID="689E19B8BB8A">
<targetbass>2</targetbass>
<actualbass>2</actualbass>
</bass>
</bassUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent() failed: %v", err)
}
if !event.HasEventType(EventTypeVolumeUpdated) {
t.Error("Expected event to have EventTypeVolumeUpdated")
}
if !event.HasEventType(EventTypeBassUpdated) {
t.Error("Expected event to have EventTypeBassUpdated")
}
eventTypes := event.GetEventTypes()
if len(eventTypes) != 2 {
t.Errorf("Expected 2 event types, got %d", len(eventTypes))
}
})
t.Run("InvalidXML", func(t *testing.T) {
xmlData := `<invalid xml>`
_, err := ParseWebSocketEvent([]byte(xmlData))
if err == nil {
t.Error("Expected error for invalid XML, got nil")
}
})
t.Run("ValidGroupUpdatedEvent", func(t *testing.T) {
// The device fans this out to both ROLE devices when a stereo
// pair is created via POST /addGroup.
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="9070658C9D4A">
<groupUpdated deviceID="9070658C9D4A">
<group id="1234567">
<name>Living Room Pair</name>
<masterDeviceId>9070658C9D4A</masterDeviceId>
<roles>
<groupRole>
<deviceId>9070658C9D4A</deviceId>
<role>LEFT</role>
<ipAddress>192.0.2.131</ipAddress>
</groupRole>
<groupRole>
<deviceId>F45EAB3115DA</deviceId>
<role>RIGHT</role>
<ipAddress>192.0.2.134</ipAddress>
</groupRole>
</roles>
<status>GROUP_OK</status>
</group>
</groupUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if !event.HasEventType(EventTypeGroupUpdated) {
t.Fatal("HasEventType(EventTypeGroupUpdated) = false, want true")
}
if event.GroupUpdated == nil {
t.Fatal("GroupUpdated is nil")
}
g := event.GroupUpdated.Group
if g.ID != "1234567" {
t.Errorf("group ID = %q, want 1234567", g.ID)
}
if g.MasterDeviceID != "9070658C9D4A" {
t.Errorf("MasterDeviceID = %q", g.MasterDeviceID)
}
if len(g.Roles.Roles) != 2 || g.Roles.Roles[0].Role != "LEFT" || g.Roles.Roles[1].Role != "RIGHT" {
t.Errorf("roles not parsed as LEFT/RIGHT: %+v", g.Roles.Roles)
}
if g.Status != "GROUP_OK" {
t.Errorf("status = %q, want GROUP_OK", g.Status)
}
})
t.Run("GroupUpdatedTeardown", func(t *testing.T) {
// On /removeGroup, the device emits a groupUpdated with an empty
// <group/> body. Parsing must surface that as IsEmpty=true so the
// UI can render "pair dissolved" cleanly.
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="9070658C9D4A">
<groupUpdated deviceID="9070658C9D4A">
<group/>
</groupUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if event.GroupUpdated == nil {
t.Fatal("GroupUpdated is nil")
}
if !event.GroupUpdated.Group.IsEmpty() {
t.Errorf("Group.IsEmpty() = false on teardown; got %+v", event.GroupUpdated.Group)
}
})
}
func TestWebSocketEvent_HasEventType(t *testing.T) {
event := &WebSocketEvent{
NowPlayingUpdated: &NowPlayingUpdatedEvent{},
VolumeUpdated: &VolumeUpdatedEvent{},
}
t.Run("HasNowPlaying", func(t *testing.T) {
if !event.HasEventType(EventTypeNowPlaying) {
t.Error("Expected event to have nowPlayingUpdated type")
}
})
t.Run("HasVolumeUpdated", func(t *testing.T) {
if !event.HasEventType(EventTypeVolumeUpdated) {
t.Error("Expected event to have volumeUpdated type")
}
})
t.Run("DoesNotHaveBass", func(t *testing.T) {
if event.HasEventType(EventTypeBassUpdated) {
t.Error("Expected event to not have bassUpdated type")
}
})
}
func TestWebSocketEvent_GetEventTypes(t *testing.T) {
event := &WebSocketEvent{
NowPlayingUpdated: &NowPlayingUpdatedEvent{},
VolumeUpdated: &VolumeUpdatedEvent{},
// No unknown events in the new structure
}
types := event.GetEventTypes()
expected := []WebSocketEventType{
EventTypeNowPlaying,
EventTypeVolumeUpdated,
}
if len(types) != len(expected) {
t.Errorf("Expected %d event types, got %d", len(expected), len(types))
return
}
for i, expectedType := range expected {
if types[i] != expectedType {
t.Errorf("Expected event type %v at index %d, got %v", expectedType, i, types[i])
}
}
}
func TestWebSocketEvent_String(t *testing.T) {
t.Run("NoEvents", func(t *testing.T) {
event := &WebSocketEvent{
DeviceID: "TEST123",
}
result := event.String()
expected := "WebSocket Event [Device: TEST123] - No events"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
})
t.Run("SingleEvent", func(t *testing.T) {
event := &WebSocketEvent{
DeviceID: "TEST123",
NowPlayingUpdated: &NowPlayingUpdatedEvent{},
}
result := event.String()
expected := "WebSocket Event [Device: TEST123] - Now Playing Updated"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
})
t.Run("MultipleEvents", func(t *testing.T) {
event := &WebSocketEvent{
DeviceID: "TEST123",
NowPlayingUpdated: &NowPlayingUpdatedEvent{},
VolumeUpdated: &VolumeUpdatedEvent{},
}
result := event.String()
expected := "WebSocket Event [Device: TEST123] - 2 events"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
})
}
func TestParseTypedEvent(t *testing.T) {
t.Run("ParseNowPlayingEvent", func(t *testing.T) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<nowPlayingUpdated deviceID="689E19B8BB8A">
<nowPlaying deviceID="689E19B8BB8A" source="SPOTIFY">
<track>Test Track</track>
<artist>Test Artist</artist>
<album>Test Album</album>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>
</nowPlayingUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent() failed: %v", err)
}
typedEvent, err := ParseTypedEvent[*NowPlayingUpdatedEvent](event, EventTypeNowPlaying)
if err != nil {
t.Fatalf("ParseTypedEvent() failed: %v", err)
}
if typedEvent.DeviceID != "689E19B8BB8A" {
t.Errorf("Expected DeviceID '689E19B8BB8A', got '%s'", typedEvent.DeviceID)
}
if typedEvent.NowPlaying.Track != "Test Track" {
t.Errorf("Expected Track 'Test Track', got '%s'", typedEvent.NowPlaying.Track)
}
})
t.Run("ParseVolumeEvent", func(t *testing.T) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<volumeUpdated deviceID="689E19B8BB8A">
<volume deviceID="689E19B8BB8A">
<targetvolume>25</targetvolume>
<actualvolume>25</actualvolume>
<muteenabled>false</muteenabled>
</volume>
</volumeUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent() failed: %v", err)
}
typedEvent, err := ParseTypedEvent[*VolumeUpdatedEvent](event, EventTypeVolumeUpdated)
if err != nil {
t.Fatalf("ParseTypedEvent() failed: %v", err)
}
if typedEvent.DeviceID != "689E19B8BB8A" {
t.Errorf("Expected DeviceID '689E19B8BB8A', got '%s'", typedEvent.DeviceID)
}
if typedEvent.Volume.TargetVolume != 25 {
t.Errorf("Expected TargetVolume 25, got %d", typedEvent.Volume.TargetVolume)
}
if typedEvent.Volume.ActualVolume != 25 {
t.Errorf("Expected ActualVolume 25, got %d", typedEvent.Volume.ActualVolume)
}
})
t.Run("EventTypeNotFound", func(t *testing.T) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<volumeUpdated deviceID="689E19B8BB8A">
<volume deviceID="689E19B8BB8A">
<targetvolume>25</targetvolume>
<actualvolume>25</actualvolume>
<muteenabled>false</muteenabled>
</volume>
</volumeUpdated>
</updates>`
event, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
t.Fatalf("ParseWebSocketEvent() failed: %v", err)
}
_, err = ParseTypedEvent[*NowPlayingUpdatedEvent](event, EventTypeNowPlaying)
if err == nil {
t.Error("Expected error when parsing non-existent event type, got nil")
}
})
}
// Benchmark tests for performance
func BenchmarkParseWebSocketEvent(b *testing.B) {
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
<nowPlayingUpdated deviceID="689E19B8BB8A">
<nowPlaying deviceID="689E19B8BB8A" source="SPOTIFY">
<track>Test Track</track>
<artist>Test Artist</artist>
<album>Test Album</album>
<playStatus>PLAY_STATE</playStatus>
</nowPlaying>
</nowPlayingUpdated>
</updates>`
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := ParseWebSocketEvent([]byte(xmlData))
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkWebSocketEventGetEventTypes(b *testing.B) {
event := &WebSocketEvent{
NowPlayingUpdated: &NowPlayingUpdatedEvent{},
VolumeUpdated: &VolumeUpdatedEvent{},
BassUpdated: &BassUpdatedEvent{},
PresetUpdated: &PresetUpdatedEvent{},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = event.GetEventTypes()
}
}
// Test helper for creating mock events
func createMockWebSocketEvent(deviceID string, eventTypes ...WebSocketEventType) *WebSocketEvent {
event := &WebSocketEvent{
DeviceID: deviceID,
Timestamp: time.Now(),
}
for _, eventType := range eventTypes {
switch eventType {
case EventTypeNowPlaying:
event.NowPlayingUpdated = &NowPlayingUpdatedEvent{}
case EventTypeVolumeUpdated:
event.VolumeUpdated = &VolumeUpdatedEvent{}
case EventTypeBassUpdated:
event.BassUpdated = &BassUpdatedEvent{}
}
}
return event
}
func TestCreateMockWebSocketEvent(t *testing.T) {
event := createMockWebSocketEvent("TEST123", EventTypeNowPlaying, EventTypeVolumeUpdated)
if event.DeviceID != "TEST123" {
t.Errorf("Expected DeviceID 'TEST123', got '%s'", event.DeviceID)
}
events := event.GetEvents()
if len(events) != 2 {
t.Errorf("Expected 2 events, got %d", len(events))
}
types := event.GetEventTypes()
if len(types) != 2 {
t.Errorf("Expected 2 event types, got %d", len(types))
}
if types[0] != EventTypeNowPlaying || types[1] != EventTypeVolumeUpdated {
t.Errorf("Event types don't match expected values")
}
}
// TestParseWebSocketEvent_UnknownElements verifies that an <updates> envelope
// whose only child is an unmodeled element (e.g. nowSelectionUpdated, observed
// on real SoundTouch 10 firmware) parses with no known event types but with
// the element captured by name, so callers can log something useful instead
// of an empty list.
func TestParseWebSocketEvent_UnknownElements(t *testing.T) {
raw := []byte(`<updates deviceID="A81B6A536A98"><nowSelectionUpdated><preset id="0"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s308770" sourceAccount="" isPresetable="true"><itemName>Willy</itemName></ContentItem></preset></nowSelectionUpdated></updates>`)
event, err := ParseWebSocketEvent(raw)
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if got := event.GetEventTypes(); len(got) != 0 {
t.Errorf("expected no known event types, got %v", got)
}
names := event.UnknownEventNames()
if len(names) != 1 || names[0] != "nowSelectionUpdated" {
t.Errorf("expected [nowSelectionUpdated], got %v", names)
}
}
// TestParseWebSocketEvent_KnownEventNoUnknowns confirms a modeled event is not
// also captured as an unknown element.
func TestParseWebSocketEvent_KnownEventNoUnknowns(t *testing.T) {
raw := []byte(`<updates deviceID="A81B6A536A98"><nowPlayingUpdated><nowPlaying deviceID="A81B6A536A98" source="TUNEIN"></nowPlaying></nowPlayingUpdated></updates>`)
event, err := ParseWebSocketEvent(raw)
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if names := event.UnknownEventNames(); len(names) != 0 {
t.Errorf("expected no unknown elements for a modeled event, got %v", names)
}
}