mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
## New Endpoints ### GET /now_playing ✅ - Rich XML models with PlayStatus, ShuffleSetting, RepeatSetting enums - Comprehensive playback information (track, artist, album, artwork, position) - Device capabilities (skip, seek, favorite functionality) - Smart display methods for different content types (music vs radio) - Duration formatting with position/total time display ### GET /sources ✅ - Complete audio source management with SourceStatus enum - Source categorization (Local/Remote, Streaming, Multiroom support) - Multiple account support (multiple Spotify accounts per device) - Availability filtering (Ready vs Unavailable sources) - Helper methods for quick capability checks ## Real Device Integration - Fetched actual XML responses from SoundTouch devices (192.168.178.28 & 192.168.178.35) - Updated all test fixtures with real device data (anonymized) - Enhanced XML models to handle all real-world fields and edge cases - Verified compatibility across different device types and configurations ## Enhanced CLI Tool - Added -nowplaying command with rich formatted output - Added -sources command with categorized source listing - Display enhancements: duration info, capabilities, source attributes - Improved build process to use ./build/ directory consistently ## Comprehensive Testing - 15+ unit tests for XML models with enum validation - Client integration tests with mock HTTP responses - Real device response validation - Edge case handling (empty states, network errors, invalid data) ## Documentation & Guidelines - Updated CLAUDE.md with build directory and real device testing guidelines - Enhanced README with comprehensive usage examples - Updated PLAN.md to reflect implementation progress - All examples use real device data patterns ## Quality Improvements - Type-safe XML unmarshaling with custom validation - Consistent error handling across all endpoints - Privacy protection (anonymized account information) - Production-ready code structure and patterns Features: ✅ GET /info - Device information ✅ GET /now_playing - Current playback status with full metadata ✅ GET /sources - Available audio sources with smart categorization ✅ UPnP device discovery ✅ Cross-platform CLI tool with rich output formatting ✅ Comprehensive test coverage with real device data ✅ Build automation with proper directory structure
381 lines
11 KiB
Go
381 lines
11 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"testing"
|
|
)
|
|
|
|
func TestSourceStatus_UnmarshalXML(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
xmlInput string
|
|
expected SourceStatus
|
|
}{
|
|
{
|
|
name: "ready status",
|
|
xmlInput: `<status>READY</status>`,
|
|
expected: SourceStatusReady,
|
|
},
|
|
{
|
|
name: "unavailable status",
|
|
xmlInput: `<status>UNAVAILABLE</status>`,
|
|
expected: SourceStatusUnavailable,
|
|
},
|
|
{
|
|
name: "error status",
|
|
xmlInput: `<status>ERROR</status>`,
|
|
expected: SourceStatusError,
|
|
},
|
|
{
|
|
name: "unknown status defaults to unavailable",
|
|
xmlInput: `<status>UNKNOWN_STATUS</status>`,
|
|
expected: SourceStatusUnavailable,
|
|
},
|
|
{
|
|
name: "empty status defaults to unavailable",
|
|
xmlInput: `<status></status>`,
|
|
expected: SourceStatusUnavailable,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var status SourceStatus
|
|
|
|
err := xml.Unmarshal([]byte(tt.xmlInput), &status)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal XML: %v", err)
|
|
}
|
|
|
|
if status != tt.expected {
|
|
t.Errorf("Expected %s, got %s", tt.expected, status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourceStatus_Methods(t *testing.T) {
|
|
tests := []struct {
|
|
status SourceStatus
|
|
isReady bool
|
|
isUnavailable bool
|
|
toString string
|
|
}{
|
|
{SourceStatusReady, true, false, "Ready"},
|
|
{SourceStatusUnavailable, false, true, "Unavailable"},
|
|
{SourceStatusError, false, false, "Error"},
|
|
{SourceStatus("UNKNOWN"), false, false, "Unknown"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.toString, func(t *testing.T) {
|
|
if tt.status.IsReady() != tt.isReady {
|
|
t.Errorf("IsReady() = %v, want %v", tt.status.IsReady(), tt.isReady)
|
|
}
|
|
if tt.status.IsUnavailable() != tt.isUnavailable {
|
|
t.Errorf("IsUnavailable() = %v, want %v", tt.status.IsUnavailable(), tt.isUnavailable)
|
|
}
|
|
if tt.status.String() != tt.toString {
|
|
t.Errorf("String() = %v, want %v", tt.status.String(), tt.toString)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourceItem_Methods(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sourceItem SourceItem
|
|
expectedDisplayName string
|
|
isSpotify bool
|
|
isBluetooth bool
|
|
isAux bool
|
|
isStreaming bool
|
|
isLocal bool
|
|
supportsMultiroom bool
|
|
}{
|
|
{
|
|
name: "spotify source with display name",
|
|
sourceItem: SourceItem{
|
|
Source: "SPOTIFY",
|
|
SourceAccount: "user@example.com",
|
|
Status: SourceStatusReady,
|
|
IsLocal: false,
|
|
MultiroomAllowed: true,
|
|
DisplayName: "user+spotify@example.com",
|
|
},
|
|
expectedDisplayName: "user+spotify@example.com",
|
|
isSpotify: true,
|
|
isBluetooth: false,
|
|
isAux: false,
|
|
isStreaming: true,
|
|
isLocal: false,
|
|
supportsMultiroom: true,
|
|
},
|
|
{
|
|
name: "aux source",
|
|
sourceItem: SourceItem{
|
|
Source: "AUX",
|
|
SourceAccount: "AUX",
|
|
Status: SourceStatusReady,
|
|
IsLocal: true,
|
|
MultiroomAllowed: true,
|
|
DisplayName: "AUX IN",
|
|
},
|
|
expectedDisplayName: "AUX IN",
|
|
isSpotify: false,
|
|
isBluetooth: false,
|
|
isAux: true,
|
|
isStreaming: false,
|
|
isLocal: true,
|
|
supportsMultiroom: true,
|
|
},
|
|
{
|
|
name: "bluetooth source without display name",
|
|
sourceItem: SourceItem{
|
|
Source: "BLUETOOTH",
|
|
Status: SourceStatusUnavailable,
|
|
IsLocal: true,
|
|
MultiroomAllowed: true,
|
|
},
|
|
expectedDisplayName: "Bluetooth",
|
|
isSpotify: false,
|
|
isBluetooth: true,
|
|
isAux: false,
|
|
isStreaming: false,
|
|
isLocal: true,
|
|
supportsMultiroom: true,
|
|
},
|
|
{
|
|
name: "tunein streaming service",
|
|
sourceItem: SourceItem{
|
|
Source: "TUNEIN",
|
|
Status: SourceStatusReady,
|
|
IsLocal: false,
|
|
MultiroomAllowed: true,
|
|
},
|
|
expectedDisplayName: "Tunein",
|
|
isSpotify: false,
|
|
isBluetooth: false,
|
|
isAux: false,
|
|
isStreaming: true,
|
|
isLocal: false,
|
|
supportsMultiroom: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.sourceItem.GetDisplayName() != tt.expectedDisplayName {
|
|
t.Errorf("GetDisplayName() = %v, want %v", tt.sourceItem.GetDisplayName(), tt.expectedDisplayName)
|
|
}
|
|
if tt.sourceItem.IsSpotify() != tt.isSpotify {
|
|
t.Errorf("IsSpotify() = %v, want %v", tt.sourceItem.IsSpotify(), tt.isSpotify)
|
|
}
|
|
if tt.sourceItem.IsBluetoothSource() != tt.isBluetooth {
|
|
t.Errorf("IsBluetoothSource() = %v, want %v", tt.sourceItem.IsBluetoothSource(), tt.isBluetooth)
|
|
}
|
|
if tt.sourceItem.IsAuxSource() != tt.isAux {
|
|
t.Errorf("IsAuxSource() = %v, want %v", tt.sourceItem.IsAuxSource(), tt.isAux)
|
|
}
|
|
if tt.sourceItem.IsStreamingService() != tt.isStreaming {
|
|
t.Errorf("IsStreamingService() = %v, want %v", tt.sourceItem.IsStreamingService(), tt.isStreaming)
|
|
}
|
|
if tt.sourceItem.IsLocalSource() != tt.isLocal {
|
|
t.Errorf("IsLocalSource() = %v, want %v", tt.sourceItem.IsLocalSource(), tt.isLocal)
|
|
}
|
|
if tt.sourceItem.SupportsMultiroom() != tt.supportsMultiroom {
|
|
t.Errorf("SupportsMultiroom() = %v, want %v", tt.sourceItem.SupportsMultiroom(), tt.supportsMultiroom)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSources_UnmarshalXML(t *testing.T) {
|
|
xmlData := `<?xml version="1.0" encoding="UTF-8" ?>
|
|
<sources deviceID="A81B6A536A98">
|
|
<sourceItem source="AUX" sourceAccount="AUX" status="READY" isLocal="true" multiroomallowed="true">AUX IN</sourceItem>
|
|
<sourceItem source="SPOTIFY" sourceAccount="user@example.com" status="READY" isLocal="false" multiroomallowed="true">user+spotify@example.com</sourceItem>
|
|
<sourceItem source="BLUETOOTH" status="UNAVAILABLE" isLocal="true" multiroomallowed="true" />
|
|
<sourceItem source="TUNEIN" status="READY" isLocal="false" multiroomallowed="true" />
|
|
</sources>`
|
|
|
|
var sources Sources
|
|
err := xml.Unmarshal([]byte(xmlData), &sources)
|
|
if err != nil {
|
|
t.Fatalf("Failed to unmarshal XML: %v", err)
|
|
}
|
|
|
|
// Test basic fields
|
|
if sources.DeviceID != "A81B6A536A98" {
|
|
t.Errorf("Expected DeviceID 'A81B6A536A98', got '%s'", sources.DeviceID)
|
|
}
|
|
|
|
if len(sources.SourceItem) != 4 {
|
|
t.Errorf("Expected 4 source items, got %d", len(sources.SourceItem))
|
|
}
|
|
|
|
// Test first source item (AUX)
|
|
auxSource := sources.SourceItem[0]
|
|
if auxSource.Source != "AUX" {
|
|
t.Errorf("Expected first source 'AUX', got '%s'", auxSource.Source)
|
|
}
|
|
if auxSource.Status != SourceStatusReady {
|
|
t.Errorf("Expected first source status Ready, got %v", auxSource.Status)
|
|
}
|
|
if !auxSource.IsLocal {
|
|
t.Error("Expected first source to be local")
|
|
}
|
|
if auxSource.DisplayName != "AUX IN" {
|
|
t.Errorf("Expected first source display name 'AUX IN', got '%s'", auxSource.DisplayName)
|
|
}
|
|
|
|
// Test Spotify source
|
|
spotifySource := sources.SourceItem[1]
|
|
if spotifySource.Source != "SPOTIFY" {
|
|
t.Errorf("Expected second source 'SPOTIFY', got '%s'", spotifySource.Source)
|
|
}
|
|
if spotifySource.SourceAccount != "user@example.com" {
|
|
t.Errorf("Expected Spotify source account 'user@example.com', got '%s'", spotifySource.SourceAccount)
|
|
}
|
|
}
|
|
|
|
func TestSources_FilterMethods(t *testing.T) {
|
|
sources := Sources{
|
|
DeviceID: "TEST123",
|
|
SourceItem: []SourceItem{
|
|
{Source: "AUX", Status: SourceStatusReady, IsLocal: true, MultiroomAllowed: true},
|
|
{Source: "SPOTIFY", SourceAccount: "user1", Status: SourceStatusReady, IsLocal: false, MultiroomAllowed: true},
|
|
{Source: "SPOTIFY", SourceAccount: "user2", Status: SourceStatusUnavailable, IsLocal: false, MultiroomAllowed: true},
|
|
{Source: "BLUETOOTH", Status: SourceStatusUnavailable, IsLocal: true, MultiroomAllowed: true},
|
|
{Source: "TUNEIN", Status: SourceStatusReady, IsLocal: false, MultiroomAllowed: true},
|
|
},
|
|
}
|
|
|
|
// Test GetAvailableSources
|
|
available := sources.GetAvailableSources()
|
|
if len(available) != 3 {
|
|
t.Errorf("Expected 3 available sources, got %d", len(available))
|
|
}
|
|
|
|
// Test GetSpotifySources
|
|
spotifySources := sources.GetSpotifySources()
|
|
if len(spotifySources) != 2 {
|
|
t.Errorf("Expected 2 Spotify sources, got %d", len(spotifySources))
|
|
}
|
|
|
|
// Test GetReadySpotifySources
|
|
readySpotify := sources.GetReadySpotifySources()
|
|
if len(readySpotify) != 1 {
|
|
t.Errorf("Expected 1 ready Spotify source, got %d", len(readySpotify))
|
|
}
|
|
|
|
// Test GetStreamingSources
|
|
streaming := sources.GetStreamingSources()
|
|
if len(streaming) != 3 { // SPOTIFY (2) + TUNEIN (1)
|
|
t.Errorf("Expected 3 streaming sources, got %d", len(streaming))
|
|
}
|
|
|
|
// Test GetLocalSources
|
|
local := sources.GetLocalSources()
|
|
if len(local) != 2 { // AUX + BLUETOOTH
|
|
t.Errorf("Expected 2 local sources, got %d", len(local))
|
|
}
|
|
|
|
// Test GetMultiroomSources
|
|
multiroom := sources.GetMultiroomSources()
|
|
if len(multiroom) != 5 { // All sources support multiroom in this test
|
|
t.Errorf("Expected 5 multiroom sources, got %d", len(multiroom))
|
|
}
|
|
|
|
// Test HasSource methods
|
|
if !sources.HasSpotify() {
|
|
t.Error("Expected HasSpotify() to return true")
|
|
}
|
|
|
|
if sources.HasBluetooth() {
|
|
t.Error("Expected HasBluetooth() to return false (unavailable)")
|
|
}
|
|
|
|
if !sources.HasAux() {
|
|
t.Error("Expected HasAux() to return true")
|
|
}
|
|
|
|
// Test count methods
|
|
if sources.GetSourceCount() != 5 {
|
|
t.Errorf("Expected total source count 5, got %d", sources.GetSourceCount())
|
|
}
|
|
|
|
if sources.GetReadySourceCount() != 3 {
|
|
t.Errorf("Expected ready source count 3, got %d", sources.GetReadySourceCount())
|
|
}
|
|
}
|
|
|
|
func TestSources_EmptyResponse(t *testing.T) {
|
|
sources := Sources{
|
|
DeviceID: "EMPTY123",
|
|
SourceItem: []SourceItem{},
|
|
}
|
|
|
|
// Test empty sources
|
|
if len(sources.GetAvailableSources()) != 0 {
|
|
t.Error("Expected no available sources for empty response")
|
|
}
|
|
|
|
if sources.HasSpotify() {
|
|
t.Error("Expected HasSpotify() to return false for empty response")
|
|
}
|
|
|
|
if sources.GetSourceCount() != 0 {
|
|
t.Errorf("Expected source count 0 for empty response, got %d", sources.GetSourceCount())
|
|
}
|
|
}
|
|
|
|
func TestSourceItem_GetDisplayName_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sourceItem SourceItem
|
|
expectedName string
|
|
}{
|
|
{
|
|
name: "with display name",
|
|
sourceItem: SourceItem{
|
|
Source: "SPOTIFY",
|
|
DisplayName: "My Spotify",
|
|
},
|
|
expectedName: "My Spotify",
|
|
},
|
|
{
|
|
name: "with source account different from source",
|
|
sourceItem: SourceItem{
|
|
Source: "SPOTIFY",
|
|
SourceAccount: "user@example.com",
|
|
},
|
|
expectedName: "user@example.com",
|
|
},
|
|
{
|
|
name: "with source account same as source",
|
|
sourceItem: SourceItem{
|
|
Source: "AUX",
|
|
SourceAccount: "AUX",
|
|
},
|
|
expectedName: "Aux",
|
|
},
|
|
{
|
|
name: "no display name or account",
|
|
sourceItem: SourceItem{
|
|
Source: "BLUETOOTH",
|
|
},
|
|
expectedName: "Bluetooth",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.sourceItem.GetDisplayName() != tt.expectedName {
|
|
t.Errorf("GetDisplayName() = %v, want %v", tt.sourceItem.GetDisplayName(), tt.expectedName)
|
|
}
|
|
})
|
|
}
|
|
}
|